Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
//! E2E integration tests for ACP management routes.
|
||||
//!
|
||||
//! Tests cover: agents list, agents/refresh, agents/test, health-check,
|
||||
//! and session-bound routes (mode/model).
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use common::{body_json, build_app, get_with_token, json_with_token, setup_and_login};
|
||||
|
||||
// ── Global ACP routes ────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_agents_returns_array() {
|
||||
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/agents", &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body = body_json(resp).await;
|
||||
assert_eq!(body["success"], true);
|
||||
assert!(body["data"].is_array());
|
||||
let agents = body["data"].as_array().unwrap();
|
||||
assert!(agents.iter().any(|a| a["agent_type"] == "nomi"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_agents_returns_array() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await;
|
||||
|
||||
let req = json_with_token("POST", "/api/agents/refresh", json!({}), &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body = body_json(resp).await;
|
||||
assert_eq!(body["success"], true);
|
||||
assert!(body["data"].is_array());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_custom_agent_nonexistent_command() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await;
|
||||
|
||||
// Endpoint was renamed from /api/agents/test to /api/agents/custom/try-connect
|
||||
// when the custom-agent CRUD routes were introduced. The new endpoint always
|
||||
// returns HTTP 200 and encodes failure in the JSON body (step = "fail_cli" or
|
||||
// "fail_acp"), so we assert on the body rather than the HTTP status.
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/agents/custom/try-connect",
|
||||
json!({ "command": "/nonexistent/path/to/agent" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = common::body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
assert_eq!(json["data"]["step"], "fail_cli");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_check_returns_status() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await;
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/agents/health-check",
|
||||
json!({ "backend": "claude" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body = body_json(resp).await;
|
||||
assert_eq!(body["success"], true);
|
||||
// available is a boolean
|
||||
assert!(body["data"]["available"].is_boolean());
|
||||
// latency should be present
|
||||
assert!(body["data"]["latency"].is_number());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_check_unknown_backend_reports_unavailable() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await;
|
||||
|
||||
// Same rationale as `detect_cli_unknown_backend_returns_null_path`:
|
||||
// unknown backends are valid at the request layer and surface as
|
||||
// `available: false` with an error string.
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/agents/health-check",
|
||||
json!({ "backend": "iFlow" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = body_json(resp).await;
|
||||
assert_eq!(body["success"], true);
|
||||
assert_eq!(body["data"]["available"], false);
|
||||
}
|
||||
|
||||
// ── Session-bound ACP routes (no active task → 404) ──────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_mode_no_active_task() {
|
||||
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/nonexistent/mode", &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_mode_no_active_task() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await;
|
||||
|
||||
let req = json_with_token(
|
||||
"PUT",
|
||||
"/api/conversations/nonexistent/mode",
|
||||
json!({ "mode": "code" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_model_no_active_task() {
|
||||
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/nonexistent/model", &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_model_no_active_task() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await;
|
||||
|
||||
let req = json_with_token(
|
||||
"PUT",
|
||||
"/api/conversations/nonexistent/model",
|
||||
json!({ "model_id": "claude-sonnet-4" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
//! E2E integration tests with mock agent tasks.
|
||||
//!
|
||||
//! Tests the message flow, confirmation system, and auxiliary routes
|
||||
//! with a mock IWorkerTaskManager that provides in-memory agents.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::broadcast;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use nomifun_ai_agent::agent_task::{AgentInstance, IAgentTask, IMockAgent};
|
||||
use nomifun_ai_agent::protocol::events::TextEventData;
|
||||
use nomifun_ai_agent::types::{BuildTaskOptions, SendMessageData};
|
||||
use nomifun_ai_agent::{AgentStreamEvent, IWorkerTaskManager};
|
||||
use nomifun_common::{AgentKillReason, AgentType, AppError, Confirmation, ConversationStatus, TimestampMs, now_ms};
|
||||
|
||||
use common::{body_json, get_with_token, json_with_token, setup_and_login};
|
||||
|
||||
// ── Mock Agent ──────────────────────────────────────────────────
|
||||
|
||||
struct MockAgent {
|
||||
conversation_id: String,
|
||||
workspace: String,
|
||||
event_tx: broadcast::Sender<AgentStreamEvent>,
|
||||
confirmations: Mutex<Vec<Confirmation>>,
|
||||
approvals: Mutex<std::collections::HashMap<String, bool>>,
|
||||
last_activity: AtomicI64,
|
||||
}
|
||||
|
||||
impl MockAgent {
|
||||
fn new(conversation_id: &str, workspace: &str) -> Self {
|
||||
let (event_tx, _) = broadcast::channel(256);
|
||||
Self {
|
||||
conversation_id: conversation_id.to_owned(),
|
||||
workspace: workspace.to_owned(),
|
||||
event_tx,
|
||||
confirmations: Mutex::new(vec![]),
|
||||
approvals: Mutex::new(std::collections::HashMap::new()),
|
||||
last_activity: AtomicI64::new(now_ms()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl IAgentTask for MockAgent {
|
||||
fn agent_type(&self) -> AgentType {
|
||||
AgentType::Acp
|
||||
}
|
||||
|
||||
fn conversation_id(&self) -> &str {
|
||||
&self.conversation_id
|
||||
}
|
||||
|
||||
fn workspace(&self) -> &str {
|
||||
&self.workspace
|
||||
}
|
||||
|
||||
fn status(&self) -> Option<ConversationStatus> {
|
||||
Some(ConversationStatus::Running)
|
||||
}
|
||||
|
||||
fn last_activity_at(&self) -> TimestampMs {
|
||||
self.last_activity.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
fn subscribe(&self) -> broadcast::Receiver<AgentStreamEvent> {
|
||||
self.event_tx.subscribe()
|
||||
}
|
||||
|
||||
async fn send_message(&self, _data: SendMessageData) -> Result<(), nomifun_ai_agent::AgentSendError> {
|
||||
self.last_activity.store(now_ms(), Ordering::Relaxed);
|
||||
// Emit a text event and finish
|
||||
let _ = self.event_tx.send(AgentStreamEvent::Text(TextEventData {
|
||||
content: "Mock response".into(),
|
||||
}));
|
||||
let _ = self.event_tx.send(AgentStreamEvent::Finish(
|
||||
nomifun_ai_agent::protocol::events::FinishEventData::default(),
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cancel(&self) -> Result<(), AppError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn kill(&self, _reason: Option<AgentKillReason>) -> Result<(), AppError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl IMockAgent for MockAgent {
|
||||
fn get_confirmations(&self) -> Vec<Confirmation> {
|
||||
self.confirmations.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn check_approval(&self, action: &str, _command_type: Option<&str>) -> bool {
|
||||
self.approvals.lock().unwrap().get(action).copied().unwrap_or(false)
|
||||
}
|
||||
|
||||
fn confirm(&self, _msg_id: &str, call_id: &str, _data: Value, always_allow: bool) -> Result<(), AppError> {
|
||||
let mut confs = self.confirmations.lock().unwrap();
|
||||
confs.retain(|c| c.call_id != call_id);
|
||||
if always_allow {
|
||||
self.approvals.lock().unwrap().insert("test_action".to_owned(), true);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mock Worker Task Manager ────────────────────────────────────
|
||||
|
||||
struct MockTaskManager {
|
||||
agents: Mutex<std::collections::HashMap<String, AgentInstance>>,
|
||||
}
|
||||
|
||||
impl MockTaskManager {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
agents: Mutex::new(std::collections::HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn insert(&self, conv_id: &str, workspace: &str) -> Arc<MockAgent> {
|
||||
let agent = Arc::new(MockAgent::new(conv_id, workspace));
|
||||
self.agents
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(conv_id.to_owned(), AgentInstance::Mock(agent.clone()));
|
||||
agent
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl IWorkerTaskManager for MockTaskManager {
|
||||
fn get_task(&self, conversation_id: &str) -> Option<AgentInstance> {
|
||||
self.agents.lock().unwrap().get(conversation_id).cloned()
|
||||
}
|
||||
|
||||
async fn get_or_build_task(
|
||||
&self,
|
||||
conversation_id: &str,
|
||||
_options: BuildTaskOptions,
|
||||
) -> Result<AgentInstance, AppError> {
|
||||
let mut agents = self.agents.lock().unwrap();
|
||||
if let Some(existing) = agents.get(conversation_id) {
|
||||
return Ok(existing.clone());
|
||||
}
|
||||
let instance = AgentInstance::Mock(Arc::new(MockAgent::new(conversation_id, "/mock-workspace")));
|
||||
agents.insert(conversation_id.to_owned(), instance.clone());
|
||||
Ok(instance)
|
||||
}
|
||||
|
||||
fn kill(&self, conversation_id: &str, _reason: Option<AgentKillReason>) -> Result<(), AppError> {
|
||||
self.agents.lock().unwrap().remove(conversation_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn kill_and_wait(
|
||||
&self,
|
||||
conversation_id: &str,
|
||||
reason: Option<AgentKillReason>,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
|
||||
let _ = self.kill(conversation_id, reason);
|
||||
Box::pin(std::future::ready(()))
|
||||
}
|
||||
|
||||
fn clear(&self) {
|
||||
self.agents.lock().unwrap().clear();
|
||||
}
|
||||
|
||||
fn active_count(&self) -> usize {
|
||||
self.agents.lock().unwrap().len()
|
||||
}
|
||||
|
||||
fn collect_idle(&self, _idle_threshold_ms: TimestampMs) -> Vec<String> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test App builder with mock agents ───────────────────────────
|
||||
|
||||
async fn build_app_with_mock_tasks() -> (axum::Router, nomifun_app::AppServices, Arc<MockTaskManager>) {
|
||||
let db = nomifun_db::init_database_memory().await.unwrap();
|
||||
let services = nomifun_app::AppServices::from_config(db, &nomifun_app::AppConfig::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mock_tm = Arc::new(MockTaskManager::new());
|
||||
let services = services.with_worker_task_manager(mock_tm.clone());
|
||||
|
||||
let router = nomifun_app::create_router(&services).await;
|
||||
(router, services, mock_tm)
|
||||
}
|
||||
|
||||
async fn create_conversation(app: &mut axum::Router, token: &str, csrf: &str, name: &str) -> String {
|
||||
let body = json!({
|
||||
"type": "acp",
|
||||
"name": name,
|
||||
"extra": { "workspace": "/project" }
|
||||
});
|
||||
let req = common::json_with_token("POST", "/api/conversations", body, 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()
|
||||
}
|
||||
|
||||
// ── Message flow with mock agent ────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_message_with_mock_agent_returns_202() {
|
||||
let (mut app, services, _mock_tm) = build_app_with_mock_tasks().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "Pass123!").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Mock Agent Test").await;
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
&format!("/api/conversations/{conv_id}/messages"),
|
||||
json!({ "content": "Hello mock agent" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::ACCEPTED);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_stream_with_mock_agent() {
|
||||
let (mut app, services, mock_tm) = build_app_with_mock_tasks().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "Pass123!").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Stop Test").await;
|
||||
mock_tm.insert(&conv_id, "/mock-workspace");
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
&format!("/api/conversations/{conv_id}/cancel"),
|
||||
json!({}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn warmup_with_mock_agent() {
|
||||
let (mut app, services, _mock_tm) = build_app_with_mock_tasks().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "Pass123!").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Warmup Test").await;
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
&format!("/api/conversations/{conv_id}/warmup"),
|
||||
json!({}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
// ── Confirmation system with mock agent ─────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_confirmations_empty() {
|
||||
let (mut app, services, mock_tm) = build_app_with_mock_tasks().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "Pass123!").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Confirm Test").await;
|
||||
mock_tm.insert(&conv_id, "/mock-workspace");
|
||||
|
||||
let req = get_with_token(&format!("/api/conversations/{conv_id}/confirmations"), &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
assert!(json["data"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn confirm_and_check_approval() {
|
||||
let (mut app, services, mock_tm) = build_app_with_mock_tasks().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "Pass123!").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Approval Test").await;
|
||||
let agent = mock_tm.insert(&conv_id, "/mock-workspace");
|
||||
|
||||
// Pre-populate a pending confirmation so the confirm endpoint can find it
|
||||
agent.confirmations.lock().unwrap().push(Confirmation {
|
||||
id: "conf-1".into(),
|
||||
call_id: "call-42".into(),
|
||||
title: Some("Allow file edit".into()),
|
||||
action: Some("test_action".into()),
|
||||
description: String::new(),
|
||||
command_type: None,
|
||||
options: vec![],
|
||||
});
|
||||
|
||||
// Confirm a call with alwaysAllow=true
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
&format!("/api/conversations/{conv_id}/confirmations/call-42/confirm"),
|
||||
json!({ "msg_id": "msg-1", "data": { "value": "allow" }, "always_allow": true }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// Check approval — should be approved for "test_action"
|
||||
let req = get_with_token(
|
||||
&format!("/api/conversations/{conv_id}/approvals/check?action=test_action"),
|
||||
&token,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
assert_eq!(json["data"]["approved"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_approval_not_set() {
|
||||
let (mut app, services, mock_tm) = build_app_with_mock_tasks().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "Pass123!").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Approval NotSet").await;
|
||||
mock_tm.insert(&conv_id, "/mock-workspace");
|
||||
|
||||
let req = get_with_token(
|
||||
&format!("/api/conversations/{conv_id}/approvals/check?action=unknown_action"),
|
||||
&token,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["approved"], false);
|
||||
}
|
||||
|
||||
// ── Auxiliary routes with mock agent ────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn slash_commands_with_mock_returns_empty() {
|
||||
let (mut app, services, mock_tm) = build_app_with_mock_tasks().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "Pass123!").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Slash Mock Test").await;
|
||||
mock_tm.insert(&conv_id, "/mock-workspace");
|
||||
|
||||
let req = get_with_token(&format!("/api/conversations/{conv_id}/slash-commands"), &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
// Mock agent is not a real AcpAgentManager, so downcast fails → 500
|
||||
// OR if agent_type check prevents downcast, returns empty array
|
||||
let status = resp.status();
|
||||
assert!(
|
||||
status == StatusCode::OK || status == StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Expected 200 or 500, got {status}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openclaw_runtime_wrong_agent_type() {
|
||||
let (mut app, services, mock_tm) = build_app_with_mock_tasks().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "Pass123!").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "OpenClaw Wrong Type").await;
|
||||
mock_tm.insert(&conv_id, "/mock-workspace");
|
||||
|
||||
let req = get_with_token(&format!("/api/conversations/{conv_id}/openclaw/runtime"), &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
// Non-OpenClaw agents return a JSON null payload instead of an
|
||||
// error — the endpoint is a best-effort diagnostic; callers that
|
||||
// need stricter typing check the payload shape themselves.
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
assert!(json["data"].is_null());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn side_question_with_mock_agent() {
|
||||
let (mut app, services, mock_tm) = build_app_with_mock_tasks().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "Pass123!").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Side Q Mock").await;
|
||||
mock_tm.insert(&conv_id, "/mock-workspace");
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
&format!("/api/conversations/{conv_id}/side-question"),
|
||||
json!({ "question": "What is this code?" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
// Mock agent is type Acp but not a real AcpAgentManager, so downcast
|
||||
// fails. The handler first checks agent_type() == Acp, then tries to
|
||||
// downcast. Since our mock returns Acp type, downcast fails → 500.
|
||||
let status = resp.status();
|
||||
assert!(
|
||||
status == StatusCode::OK || status == StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Expected 200 or 500, got {status}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! Provider health-check route auth and validation tests.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::json;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use common::{body_json, build_app, json_with_token, setup_and_login};
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_health_check_unauthenticated_is_rejected() {
|
||||
let (app, _services) = build_app().await;
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/agents/provider-health-check")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&json!({"provider_id": "p1", "model": "gpt-4o"})).unwrap(),
|
||||
))
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert!(
|
||||
resp.status() == StatusCode::UNAUTHORIZED || resp.status() == StatusCode::FORBIDDEN,
|
||||
"expected auth rejection, got {}",
|
||||
resp.status()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_health_check_requires_csrf_for_post() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/agents/provider-health-check")
|
||||
.header("content-type", "application/json")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&json!({"provider_id": "p1", "model": "gpt-4o"})).unwrap(),
|
||||
))
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_health_check_validates_required_fields() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/agents/provider-health-check",
|
||||
json!({"provider_id": "", "model": "gpt-4o"}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["code"], "BAD_REQUEST");
|
||||
assert!(
|
||||
json["error"]
|
||||
.as_str()
|
||||
.is_some_and(|message| message.contains("provider_id is required")),
|
||||
"expected provider_id validation error, got {json}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
mod common;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use common::build_app;
|
||||
|
||||
#[tokio::test]
|
||||
async fn public_logo_assets_do_not_require_auth() {
|
||||
let (app, _services) = build_app().await;
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/assets/logos/ai-major/claude.svg")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
|
||||
assert_eq!(
|
||||
response.headers()[header::CACHE_CONTROL],
|
||||
"public, max-age=31536000, immutable"
|
||||
);
|
||||
assert!(response.headers().contains_key(header::ETAG));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn public_logo_assets_honor_if_none_match() {
|
||||
let (app, _services) = build_app().await;
|
||||
let first = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/assets/logos/ai-major/claude.svg")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let etag = first.headers()[header::ETAG].clone();
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/assets/logos/ai-major/claude.svg")
|
||||
.header(header::IF_NONE_MATCH, etag)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,443 @@
|
||||
//! End-to-end integration tests for the complete authentication flow.
|
||||
//!
|
||||
//! These tests exercise the full application stack (security headers, CSRF,
|
||||
//! auth routes) via `nomifun_app::create_router`, covering test-plan items
|
||||
//! T12 (security middleware), T13 (token extraction), T14 (initial bootstrap).
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use http_body_util::BodyExt;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use nomifun_app::{AppConfig, AppServices};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn build_app() -> (axum::Router, AppServices) {
|
||||
let db = nomifun_db::init_database_memory().await.unwrap();
|
||||
let services = AppServices::from_config(db, &AppConfig::default()).await.unwrap();
|
||||
let router = nomifun_app::create_router(&services).await;
|
||||
(router, services)
|
||||
}
|
||||
|
||||
async fn body_json(resp: axum::response::Response) -> serde_json::Value {
|
||||
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
|
||||
serde_json::from_slice(&bytes).unwrap()
|
||||
}
|
||||
|
||||
/// Extract the CSRF token from a Set-Cookie header.
|
||||
fn extract_csrf_token(resp: &axum::response::Response) -> Option<String> {
|
||||
resp.headers()
|
||||
.get_all(header::SET_COOKIE)
|
||||
.iter()
|
||||
.filter_map(|v| v.to_str().ok())
|
||||
.find(|s| s.starts_with("nomifun-csrf-token="))
|
||||
.map(|s| {
|
||||
s.strip_prefix("nomifun-csrf-token=")
|
||||
.unwrap()
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_owned()
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract the session token from a Set-Cookie header.
|
||||
fn extract_session_token(resp: &axum::response::Response) -> Option<String> {
|
||||
resp.headers()
|
||||
.get_all(header::SET_COOKIE)
|
||||
.iter()
|
||||
.filter_map(|v| v.to_str().ok())
|
||||
.find(|s| s.starts_with("nomifun-session="))
|
||||
.and_then(|s| {
|
||||
let value = s.strip_prefix("nomifun-session=")?.split(';').next()?.to_owned();
|
||||
if value.is_empty() { None } else { Some(value) }
|
||||
})
|
||||
}
|
||||
|
||||
fn get_request(uri: &str) -> Request<Body> {
|
||||
Request::builder().method("GET").uri(uri).body(Body::empty()).unwrap()
|
||||
}
|
||||
|
||||
fn get_with_token(uri: &str, token: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(uri)
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn get_with_cookie(uri: &str, token: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(uri)
|
||||
.header("cookie", format!("nomifun-session={token}"))
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn post_json_login(uri: &str, body: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(uri)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(body.to_owned()))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn post_json_with_csrf(uri: &str, body: &str, token: &str, csrf: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(uri)
|
||||
.header("content-type", "application/json")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("x-csrf-token", csrf)
|
||||
.header("cookie", format!("nomifun-csrf-token={csrf}"))
|
||||
.body(Body::from(body.to_owned()))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Set up a user and login, returning (session_token, csrf_token).
|
||||
///
|
||||
/// Seeded `system_default_user` already owns `username = "admin"` with an empty
|
||||
/// hash; if the test uses that name, overwrite the seed row in place. Other
|
||||
/// usernames use the normal create_user path.
|
||||
async fn setup_and_login(
|
||||
app: &mut axum::Router,
|
||||
services: &AppServices,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> (String, String) {
|
||||
// Create user
|
||||
let hash = nomifun_auth::hash_password(password).unwrap();
|
||||
if username == "admin" {
|
||||
services
|
||||
.user_repo
|
||||
.set_system_user_credentials(username, &hash)
|
||||
.await
|
||||
.unwrap();
|
||||
} else {
|
||||
services.user_repo.create_user(username, &hash).await.unwrap();
|
||||
}
|
||||
|
||||
// Get CSRF token from a GET request first
|
||||
let resp = app.clone().oneshot(get_request("/api/auth/status")).await.unwrap();
|
||||
let csrf = extract_csrf_token(&resp).expect("CSRF cookie should be set");
|
||||
|
||||
// Login (exempt from CSRF)
|
||||
let body = format!(r#"{{"username":"{username}","password":"{password}"}}"#);
|
||||
let resp = app.clone().oneshot(post_json_login("/login", &body)).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK, "login should succeed");
|
||||
|
||||
let json = body_json(resp).await;
|
||||
let token = json["token"].as_str().unwrap().to_owned();
|
||||
|
||||
(token, csrf)
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// T12. Security Middleware
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn t12_1_security_headers_on_all_responses() {
|
||||
let (app, _services) = build_app().await;
|
||||
|
||||
let resp = app.oneshot(get_request("/health")).await.unwrap();
|
||||
|
||||
assert_eq!(resp.headers().get("x-frame-options").unwrap(), "DENY");
|
||||
assert_eq!(resp.headers().get("x-content-type-options").unwrap(), "nosniff");
|
||||
assert_eq!(resp.headers().get("x-xss-protection").unwrap(), "1; mode=block");
|
||||
assert_eq!(
|
||||
resp.headers().get("referrer-policy").unwrap(),
|
||||
"strict-origin-when-cross-origin"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t12_1_security_headers_on_error_responses() {
|
||||
let (app, _services) = build_app().await;
|
||||
|
||||
// 404 response should still have security headers
|
||||
let resp = app.oneshot(get_request("/nonexistent")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
assert_eq!(resp.headers().get("x-frame-options").unwrap(), "DENY");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t12_2_csrf_blocks_post_without_token() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// POST /logout without CSRF token → 403
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/logout")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
let json = body_json(resp).await;
|
||||
assert!(
|
||||
json["error"].as_str().unwrap_or("").contains("CSRF"),
|
||||
"error message should mention CSRF"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t12_2_csrf_allows_post_with_valid_token() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// POST /logout with valid CSRF token → 200
|
||||
let req = post_json_with_csrf("/logout", "", &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t12_2_csrf_exempt_paths() {
|
||||
let (app, _services) = build_app().await;
|
||||
|
||||
// POST /login is exempt from CSRF
|
||||
let req = post_json_login("/login", r#"{"username":"x","password":"y"}"#);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
// Should get 401 (auth failure), not 403 (CSRF failure)
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
// POST /api/auth/qr-login is exempt from CSRF
|
||||
let req = post_json_login("/api/auth/qr-login", r#"{"qr_token":"fake"}"#);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t12_3_session_cookie_attributes() {
|
||||
let (app, services) = build_app().await;
|
||||
let hash = nomifun_auth::hash_password("StrongP@ss1").unwrap();
|
||||
// system_default_user is seeded with username='admin'; overwrite its empty
|
||||
// password in place instead of creating a duplicate.
|
||||
services
|
||||
.user_repo
|
||||
.set_system_user_credentials("admin", &hash)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let req = post_json_login("/login", r#"{"username":"admin","password":"StrongP@ss1"}"#);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let set_cookie = resp
|
||||
.headers()
|
||||
.get_all(header::SET_COOKIE)
|
||||
.iter()
|
||||
.filter_map(|v| v.to_str().ok())
|
||||
.find(|s| s.starts_with("nomifun-session="))
|
||||
.expect("session cookie should be set");
|
||||
|
||||
assert!(set_cookie.contains("HttpOnly"));
|
||||
assert!(set_cookie.contains("SameSite="));
|
||||
assert!(set_cookie.contains("Max-Age="));
|
||||
// Max-Age should be 30 days
|
||||
let expected_max_age = format!("Max-Age={}", 30 * 24 * 60 * 60);
|
||||
assert!(set_cookie.contains(&expected_max_age));
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// T13. Token Extraction Strategy
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn t13_1_authorization_header_takes_priority() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// Both header and cookie present; header should be used
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/auth/user")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("cookie", "nomifun-session=invalid_token")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["user"]["username"], "admin");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t13_2_cookie_fallback() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// Only cookie, no Authorization header
|
||||
let req = get_with_cookie("/api/auth/user", &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["user"]["username"], "admin");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t13_3_no_token_fails() {
|
||||
let (app, _services) = build_app().await;
|
||||
|
||||
let req = get_request("/api/auth/user");
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// T14. Initial Bootstrap Flow
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn t14_1_fresh_system_needs_setup() {
|
||||
let (app, _services) = build_app().await;
|
||||
|
||||
let resp = app.oneshot(get_request("/api/auth/status")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
assert_eq!(json["needs_setup"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t14_2_setup_then_login() {
|
||||
let (app, services) = build_app().await;
|
||||
|
||||
// Fresh system: needsSetup=true
|
||||
let resp = app.clone().oneshot(get_request("/api/auth/status")).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["needs_setup"], true);
|
||||
|
||||
// Set system user credentials (simulating initial setup)
|
||||
let hash = nomifun_auth::hash_password("Admin@Pass1").unwrap();
|
||||
services
|
||||
.user_repo
|
||||
.set_system_user_credentials("admin", &hash)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Now needsSetup=false
|
||||
let resp = app.clone().oneshot(get_request("/api/auth/status")).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["needs_setup"], false);
|
||||
|
||||
// Login with new credentials
|
||||
let req = post_json_login("/login", r#"{"username":"admin","password":"Admin@Pass1"}"#);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
assert_eq!(json["user"]["username"], "admin");
|
||||
|
||||
// Authenticated status check
|
||||
let token = json["token"].as_str().unwrap();
|
||||
let req = get_with_token("/api/auth/status", token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["is_authenticated"], true);
|
||||
assert_eq!(json["needs_setup"], false);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Full E2E Flow: setup → login → get user → change password → logout
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_auth_flow_e2e() {
|
||||
let (app, services) = build_app().await;
|
||||
|
||||
// 1. Check initial status
|
||||
let resp = app.clone().oneshot(get_request("/api/auth/status")).await.unwrap();
|
||||
let csrf = extract_csrf_token(&resp).expect("CSRF cookie on first request");
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["needs_setup"], true);
|
||||
|
||||
// 2. Setup user
|
||||
let hash = nomifun_auth::hash_password("Initial@Pass1").unwrap();
|
||||
services
|
||||
.user_repo
|
||||
.set_system_user_credentials("admin", &hash)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 3. Login
|
||||
let req = post_json_login("/login", r#"{"username":"admin","password":"Initial@Pass1"}"#);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let session_token = extract_session_token(&resp).expect("session cookie set");
|
||||
let json = body_json(resp).await;
|
||||
let token = json["token"].as_str().unwrap().to_owned();
|
||||
|
||||
// Verify session token matches response body token
|
||||
assert_eq!(session_token, token);
|
||||
|
||||
// 4. Get current user
|
||||
let req = get_with_token("/api/auth/user", &token);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["user"]["username"], "admin");
|
||||
|
||||
// 5. Change password (needs CSRF)
|
||||
let req = post_json_with_csrf(
|
||||
"/api/auth/change-password",
|
||||
r#"{"current_password":"Initial@Pass1","new_password":"Updated@Pass2"}"#,
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// 6. Old token invalidated after password change
|
||||
let req = get_with_token("/api/auth/user", &token);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
// 7. Login with new password
|
||||
let req = post_json_login("/login", r#"{"username":"admin","password":"Updated@Pass2"}"#);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
let new_token = json["token"].as_str().unwrap().to_owned();
|
||||
|
||||
// 8. Logout (needs CSRF)
|
||||
let req = post_json_with_csrf("/logout", "", &new_token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// 9. Token invalid after logout
|
||||
let req = get_with_token("/api/auth/user", &new_token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// CSRF cookie is set on first response
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn csrf_cookie_set_on_first_get() {
|
||||
let (app, _services) = build_app().await;
|
||||
|
||||
let resp = app.oneshot(get_request("/health")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let csrf = extract_csrf_token(&resp);
|
||||
assert!(csrf.is_some(), "CSRF cookie should be set on first request");
|
||||
assert_eq!(csrf.unwrap().len(), 64, "CSRF token should be 64 hex chars");
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
//! 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);
|
||||
}
|
||||
@@ -0,0 +1,619 @@
|
||||
//! Channel integration E2E tests.
|
||||
//!
|
||||
//! Covers test-plan §1-5: plugin CRUD, pairing flow, user management,
|
||||
//! session management, settings sync.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use common::{body_json, build_app, get_with_token, json_with_token, setup_and_login};
|
||||
|
||||
/// Seed a `tg-1` telegram bot channel so pairing/user rows satisfy the
|
||||
/// FK channel_id → assistant_plugins(id) added in migration 004.
|
||||
async fn seed_telegram_channel(repo: &std::sync::Arc<dyn nomifun_db::IChannelRepository>) {
|
||||
use nomifun_common::now_ms;
|
||||
use nomifun_db::models::ChannelPluginRow;
|
||||
repo.upsert_plugin(&ChannelPluginRow {
|
||||
id: "tg-1".into(),
|
||||
r#type: "telegram".into(),
|
||||
name: "Test Bot".into(),
|
||||
enabled: true,
|
||||
config: "{}".into(),
|
||||
status: None,
|
||||
last_connected: None,
|
||||
companion_id: None,
|
||||
bot_key: None,
|
||||
created_at: now_ms(),
|
||||
updated_at: now_ms(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// §1 Plugin management
|
||||
// ===========================================================================
|
||||
|
||||
// PS-1: Get plugins when none exist
|
||||
#[tokio::test]
|
||||
async fn get_plugins_empty() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = get_with_token("/api/channel/plugins", &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["success"].as_bool().unwrap());
|
||||
let data = json["data"].as_array().unwrap();
|
||||
assert_eq!(data.len(), 9);
|
||||
let types: std::collections::HashSet<_> = data.iter().filter_map(|item| item["type"].as_str()).collect();
|
||||
assert_eq!(
|
||||
types,
|
||||
std::collections::HashSet::from(["telegram", "lark", "dingtalk", "slack", "discord", "matrix", "mattermost", "weixin", "wecom",])
|
||||
);
|
||||
assert!(data.iter().all(|item| item["enabled"] == false));
|
||||
}
|
||||
|
||||
// PS-3: Unauthenticated request returns 403
|
||||
#[tokio::test]
|
||||
async fn get_plugins_unauthenticated() {
|
||||
let (app, _services) = build_app().await;
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/channel/plugins")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// EP-3: Enable without any addressing info fails.
|
||||
// `plugin_id` is optional since the per-companion multi-bot refactor (absent id +
|
||||
// `plugin_type` is the create path), so the request now deserializes and the
|
||||
// failure surfaces as success=false from the manager instead of HTTP 400.
|
||||
#[tokio::test]
|
||||
async fn enable_plugin_missing_plugin_id() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/channel/plugins/enable",
|
||||
json!({ "config": {} }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
let data = &json["data"];
|
||||
assert!(!data["success"].as_bool().unwrap());
|
||||
assert!(data["error"].as_str().unwrap().contains("plugin_type is required"));
|
||||
}
|
||||
|
||||
// EP-4: Enable missing config fails
|
||||
#[tokio::test]
|
||||
async fn enable_plugin_missing_config() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/channel/plugins/enable",
|
||||
json!({ "plugin_id": "telegram" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// EP-5: Enable invalid plugin type returns error in response body
|
||||
#[tokio::test]
|
||||
async fn enable_plugin_invalid_type() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/channel/plugins/enable",
|
||||
json!({
|
||||
"plugin_id": "nonexistent",
|
||||
"config": { "credentials": { "token": "x" } }
|
||||
}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
let data = &json["data"];
|
||||
assert!(!data["success"].as_bool().unwrap());
|
||||
assert!(data["error"].as_str().unwrap().contains("Invalid plugin type"));
|
||||
}
|
||||
|
||||
// DP-3: Disable missing pluginId fails
|
||||
#[tokio::test]
|
||||
async fn disable_plugin_missing_plugin_id() {
|
||||
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("POST", "/api/channel/plugins/disable", json!({}), &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// DP-2: Disable non-existent plugin returns success=false (not registered)
|
||||
#[tokio::test]
|
||||
async fn disable_plugin_not_registered() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/channel/plugins/disable",
|
||||
json!({ "plugin_id": "telegram" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
// Plugin was never enabled, so disable returns success=false with error
|
||||
assert!(!json["data"]["success"].as_bool().unwrap());
|
||||
assert!(json["data"]["error"].as_str().is_some());
|
||||
}
|
||||
|
||||
// TP-4: Test plugin missing pluginId fails
|
||||
#[tokio::test]
|
||||
async fn test_plugin_missing_plugin_id() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/channel/plugins/test",
|
||||
json!({ "token": "xxx" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// TP-5: Test plugin missing token fails
|
||||
#[tokio::test]
|
||||
async fn test_plugin_missing_token() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/channel/plugins/test",
|
||||
json!({ "plugin_id": "telegram" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// §2 Pairing management
|
||||
// ===========================================================================
|
||||
|
||||
// PP-1: No pending pairings
|
||||
#[tokio::test]
|
||||
async fn get_pairings_empty() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = get_with_token("/api/channel/pairings", &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["success"].as_bool().unwrap());
|
||||
assert!(json["data"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
// AP-6: Approve missing code fails
|
||||
#[tokio::test]
|
||||
async fn approve_pairing_missing_code() {
|
||||
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("POST", "/api/channel/pairings/approve", json!({}), &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// AP-3: Approve non-existent code returns 404
|
||||
#[tokio::test]
|
||||
async fn approve_pairing_not_found() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/channel/pairings/approve",
|
||||
json!({ "code": "000000" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// RP-3: Reject non-existent code returns 404
|
||||
#[tokio::test]
|
||||
async fn reject_pairing_not_found() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/channel/pairings/reject",
|
||||
json!({ "code": "000000" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// §3 User management
|
||||
// ===========================================================================
|
||||
|
||||
// GU-1: No authorized users
|
||||
#[tokio::test]
|
||||
async fn get_users_empty() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = get_with_token("/api/channel/users", &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["success"].as_bool().unwrap());
|
||||
assert!(json["data"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
// RU-5: Revoke missing userId fails
|
||||
#[tokio::test]
|
||||
async fn revoke_user_missing_user_id() {
|
||||
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("POST", "/api/channel/users/revoke", json!({}), &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// RU-4: Revoke non-existent user returns 404
|
||||
#[tokio::test]
|
||||
async fn revoke_user_not_found() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/channel/users/revoke",
|
||||
json!({ "user_id": "nonexistent" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// §4 Session management
|
||||
// ===========================================================================
|
||||
|
||||
// GS-1: No active sessions
|
||||
#[tokio::test]
|
||||
async fn get_sessions_empty() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = get_with_token("/api/channel/sessions", &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["success"].as_bool().unwrap());
|
||||
assert!(json["data"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// §5 Settings sync
|
||||
// ===========================================================================
|
||||
|
||||
// SS-1: Sync valid platform clears sessions
|
||||
#[tokio::test]
|
||||
async fn sync_settings_valid() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/channel/settings/sync",
|
||||
json!({ "platform": "telegram" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["success"].as_bool().unwrap());
|
||||
assert!(json["data"]["success"].as_bool().unwrap());
|
||||
}
|
||||
|
||||
// SS-2: Sync missing platform fails deserialization
|
||||
#[tokio::test]
|
||||
async fn sync_settings_missing_platform() {
|
||||
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("POST", "/api/channel/settings/sync", json!({}), &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// SS-3: Sync invalid platform fails validation
|
||||
#[tokio::test]
|
||||
async fn sync_settings_invalid_platform() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/channel/settings/sync",
|
||||
json!({ "platform": "invalid" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Full pairing → user → session lifecycle
|
||||
// ===========================================================================
|
||||
|
||||
/// Test the complete pairing flow using direct DB access for the parts
|
||||
/// that normally come from IM platform (pairing request).
|
||||
#[tokio::test]
|
||||
async fn pairing_approve_creates_user() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// Create a pairing request directly via the pairing service
|
||||
let pool = services.database.pool().clone();
|
||||
let repo: std::sync::Arc<dyn nomifun_db::IChannelRepository> =
|
||||
std::sync::Arc::new(nomifun_db::SqliteChannelRepository::new(pool));
|
||||
let pairing_svc = nomifun_channel::pairing::PairingService::new(repo.clone(), services.event_bus.clone());
|
||||
|
||||
// The pairing/user rows carry an FK channel_id → assistant_plugins(id), so
|
||||
// the telegram bot channel must exist before request_pairing runs.
|
||||
seed_telegram_channel(&repo).await;
|
||||
|
||||
let code = pairing_svc
|
||||
.request_pairing("tg_user_42", "telegram", "tg-1", Some("Alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify pairing appears in pending list
|
||||
let req = get_with_token("/api/channel/pairings", &token);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
let pairings = json["data"].as_array().unwrap();
|
||||
assert_eq!(pairings.len(), 1);
|
||||
assert_eq!(pairings[0]["code"], code);
|
||||
assert_eq!(pairings[0]["platform_user_id"], "tg_user_42");
|
||||
assert_eq!(pairings[0]["platform_type"], "telegram");
|
||||
assert_eq!(pairings[0]["display_name"], "Alice");
|
||||
|
||||
// Approve the pairing
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/channel/pairings/approve",
|
||||
json!({ "code": code }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["data"]["success"].as_bool().unwrap());
|
||||
|
||||
// Verify user appears in authorized users
|
||||
let req = get_with_token("/api/channel/users", &token);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
let users = json["data"].as_array().unwrap();
|
||||
assert_eq!(users.len(), 1);
|
||||
assert_eq!(users[0]["platform_user_id"], "tg_user_42");
|
||||
assert_eq!(users[0]["platform_type"], "telegram");
|
||||
assert_eq!(users[0]["display_name"], "Alice");
|
||||
let user_id = users[0]["id"].as_str().unwrap().to_owned();
|
||||
|
||||
// Verify double-approve fails
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/channel/pairings/approve",
|
||||
json!({ "code": code }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
// Pairing should no longer appear in pending list
|
||||
let req = get_with_token("/api/channel/pairings", &token);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["data"].as_array().unwrap().is_empty());
|
||||
|
||||
// Revoke the user
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/channel/users/revoke",
|
||||
json!({ "user_id": user_id }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["data"]["success"].as_bool().unwrap());
|
||||
|
||||
// Verify user no longer in list
|
||||
let req = get_with_token("/api/channel/users", &token);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["data"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// Test pairing rejection flow.
|
||||
#[tokio::test]
|
||||
async fn pairing_reject_removes_from_pending() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// Create a pairing request
|
||||
let pool = services.database.pool().clone();
|
||||
let repo: std::sync::Arc<dyn nomifun_db::IChannelRepository> =
|
||||
std::sync::Arc::new(nomifun_db::SqliteChannelRepository::new(pool));
|
||||
let pairing_svc = nomifun_channel::pairing::PairingService::new(repo.clone(), services.event_bus.clone());
|
||||
|
||||
// FK channel_id → assistant_plugins(id): seed the bot channel first.
|
||||
seed_telegram_channel(&repo).await;
|
||||
|
||||
let code = pairing_svc
|
||||
.request_pairing("tg_user_99", "telegram", "tg-1", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Reject the pairing
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/channel/pairings/reject",
|
||||
json!({ "code": code }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["data"]["success"].as_bool().unwrap());
|
||||
|
||||
// Verify pairing no longer in pending list
|
||||
let req = get_with_token("/api/channel/pairings", &token);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["data"].as_array().unwrap().is_empty());
|
||||
|
||||
// Verify no user was created
|
||||
let req = get_with_token("/api/channel/users", &token);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["data"].as_array().unwrap().is_empty());
|
||||
|
||||
// Verify reject same code again fails (already processed)
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/channel/pairings/reject",
|
||||
json!({ "code": code }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Plugin enable/disable with real telegram factory
|
||||
// ===========================================================================
|
||||
|
||||
/// Enable a Telegram plugin with mock-friendly config, verify status
|
||||
/// appears in the plugin list, then disable it.
|
||||
#[tokio::test]
|
||||
async fn enable_disable_plugin_lifecycle() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// Enable Telegram plugin (will fail connecting to real API, but
|
||||
// the error is captured in response, not an HTTP error)
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/channel/plugins/enable",
|
||||
json!({
|
||||
"plugin_id": "telegram",
|
||||
"config": {
|
||||
"credentials": { "token": "000000000:FAKE_TOKEN" },
|
||||
"config": { "mode": "polling" }
|
||||
}
|
||||
}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// The result may be success or failure depending on network —
|
||||
// either way, the plugin should appear in the list
|
||||
let req = get_with_token("/api/channel/plugins", &token);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
let plugins = json["data"].as_array().unwrap();
|
||||
assert_eq!(plugins.len(), 9);
|
||||
let telegram = plugins
|
||||
.iter()
|
||||
.find(|plugin| plugin["plugin_id"] == "telegram")
|
||||
.expect("telegram plugin should be present");
|
||||
assert_eq!(telegram["type"], "telegram");
|
||||
assert_eq!(telegram["name"], "Telegram Bot");
|
||||
assert_eq!(telegram["enabled"], true);
|
||||
|
||||
// Disable the plugin
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/channel/plugins/disable",
|
||||
json!({ "plugin_id": "telegram" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["data"]["success"].as_bool().unwrap());
|
||||
|
||||
// Verify plugin is now disabled
|
||||
let req = get_with_token("/api/channel/plugins", &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
let plugins = json["data"].as_array().unwrap();
|
||||
assert_eq!(plugins.len(), 9);
|
||||
let telegram = plugins
|
||||
.iter()
|
||||
.find(|plugin| plugin["plugin_id"] == "telegram")
|
||||
.expect("telegram plugin should remain listed after disable");
|
||||
assert!(!telegram["enabled"].as_bool().unwrap());
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
//! Shared test helpers for nomifun-app E2E tests.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use http_body_util::BodyExt;
|
||||
use tower::ServiceExt;
|
||||
use wiremock::MockServer;
|
||||
|
||||
use nomifun_ai_agent::{AgentInstance, IAgentTask, IMockAgent, WorkerTaskManagerImpl};
|
||||
use nomifun_app::{AppConfig, AppServices, build_module_states, create_router, create_router_with_states};
|
||||
use nomifun_extension::{ExternalPathsManager, SkillPaths, SkillRouterState};
|
||||
use nomifun_file::FileService;
|
||||
use nomifun_system::VersionCheckService;
|
||||
|
||||
pub async fn build_app() -> (axum::Router, AppServices) {
|
||||
let db = nomifun_db::init_database_memory().await.unwrap();
|
||||
let services = AppServices::from_config(db, &AppConfig::default()).await.unwrap();
|
||||
let router = create_router(&services).await;
|
||||
(router, services)
|
||||
}
|
||||
|
||||
/// Build an app whose skill router reads from the given temp directories.
|
||||
///
|
||||
/// Use for HTTP integration tests that need deterministic on-disk layouts
|
||||
/// (E1 `/api/skills`, E2 `/api/skills/builtin-auto`, E3/E4 built-in reads,
|
||||
/// E5 `/api/skills/info`). Returns the router, services, and the
|
||||
/// `SkillPaths` so the test can seed fixtures at known locations.
|
||||
#[allow(dead_code)]
|
||||
pub async fn build_app_with_skill_paths(root: &std::path::Path) -> (axum::Router, AppServices, SkillPaths) {
|
||||
let db = nomifun_db::init_database_memory().await.unwrap();
|
||||
let services = AppServices::from_config(db, &AppConfig::default()).await.unwrap();
|
||||
let (mut states, _) = build_module_states(&services).await;
|
||||
|
||||
let builtin_dir = root.join("builtin-skills");
|
||||
let paths = SkillPaths {
|
||||
data_dir: root.to_path_buf(),
|
||||
user_skills_dir: root.join("skills"),
|
||||
cron_skills_dir: root.join("cron").join("skills"),
|
||||
builtin_skills_dir: builtin_dir.clone(),
|
||||
builtin_rules_dir: root.join("builtin-rules"),
|
||||
assistant_rules_dir: root.join("assistant-rules"),
|
||||
assistant_skills_dir: root.join("assistant-skills"),
|
||||
};
|
||||
for dir in [
|
||||
&paths.user_skills_dir,
|
||||
&builtin_dir,
|
||||
&paths.builtin_rules_dir,
|
||||
&paths.assistant_rules_dir,
|
||||
&paths.assistant_skills_dir,
|
||||
] {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
let ext_paths_mgr = std::sync::Arc::new(ExternalPathsManager::with_file(root.join("paths.json")).await);
|
||||
states.skill = SkillRouterState {
|
||||
skill_paths: paths.clone(),
|
||||
external_paths_manager: ext_paths_mgr,
|
||||
assistant_dispatcher: states.skill.assistant_dispatcher.clone(),
|
||||
skill_tag_repo: std::sync::Arc::new(nomifun_db::SqliteSkillTagRepository::new(
|
||||
services.database.pool().clone(),
|
||||
)),
|
||||
builtin_skill_tags: std::sync::Arc::new(std::collections::HashMap::new()),
|
||||
};
|
||||
|
||||
let router = create_router_with_states(&services, states);
|
||||
(router, services, paths)
|
||||
}
|
||||
|
||||
pub async fn build_app_with_noop_opener() -> (axum::Router, AppServices) {
|
||||
let db = nomifun_db::init_database_memory().await.unwrap();
|
||||
let services = AppServices::from_config(db, &AppConfig::default()).await.unwrap();
|
||||
let (mut states, _) = build_module_states(&services).await;
|
||||
states.shell.shell_service = std::sync::Arc::new(nomifun_shell::ShellService::new(std::sync::Arc::new(
|
||||
nomifun_shell::NoopSystemOpener,
|
||||
)));
|
||||
let router = create_router_with_states(&services, states);
|
||||
(router, services)
|
||||
}
|
||||
|
||||
pub async fn build_app_with_file_roots(allowed_roots: Vec<std::path::PathBuf>) -> (axum::Router, AppServices) {
|
||||
let db = nomifun_db::init_database_memory().await.unwrap();
|
||||
let services = AppServices::from_config(db, &AppConfig::default()).await.unwrap();
|
||||
let (mut states, _) = build_module_states(&services).await;
|
||||
states.file.file_service = std::sync::Arc::new(FileService::new(services.event_bus.clone(), allowed_roots));
|
||||
let router = create_router_with_states(&services, states);
|
||||
(router, services)
|
||||
}
|
||||
|
||||
pub async fn build_app_with_mock_version(
|
||||
current_version: &str,
|
||||
mock_server: &MockServer,
|
||||
) -> (axum::Router, AppServices) {
|
||||
let db = nomifun_db::init_database_memory().await.unwrap();
|
||||
let services = AppServices::from_config(db, &AppConfig::default()).await.unwrap();
|
||||
let (mut states, _) = build_module_states(&services).await;
|
||||
states.system.version_check_service =
|
||||
VersionCheckService::with_api_base(reqwest::Client::new(), current_version.to_owned(), mock_server.uri());
|
||||
let router = create_router_with_states(&services, states);
|
||||
(router, services)
|
||||
}
|
||||
|
||||
/// Build app with a mock worker task manager that returns noop agents.
|
||||
///
|
||||
/// Use for tests that exercise session/warmup paths (team ensure_session,
|
||||
/// send_message) where spawning a real CLI process is not feasible.
|
||||
pub async fn build_app_with_mock_agents() -> (axum::Router, AppServices) {
|
||||
let db = nomifun_db::init_database_memory().await.unwrap();
|
||||
let factory: std::sync::Arc<
|
||||
dyn Fn(
|
||||
nomifun_ai_agent::types::BuildTaskOptions,
|
||||
) -> futures_util::future::BoxFuture<'static, Result<AgentInstance, nomifun_common::AppError>>
|
||||
+ Send
|
||||
+ Sync,
|
||||
> = std::sync::Arc::new(|opts| {
|
||||
Box::pin(async move {
|
||||
Ok(AgentInstance::Mock(std::sync::Arc::new(NoopMockAgent {
|
||||
conversation_id: opts.conversation_id,
|
||||
})))
|
||||
})
|
||||
});
|
||||
let wtm: std::sync::Arc<dyn nomifun_ai_agent::IWorkerTaskManager> =
|
||||
std::sync::Arc::new(WorkerTaskManagerImpl::new(factory));
|
||||
let services = AppServices::from_config(db, &AppConfig::default())
|
||||
.await
|
||||
.unwrap()
|
||||
.with_worker_task_manager(wtm);
|
||||
let router = create_router(&services).await;
|
||||
(router, services)
|
||||
}
|
||||
|
||||
struct NoopMockAgent {
|
||||
conversation_id: String,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl IAgentTask for NoopMockAgent {
|
||||
fn agent_type(&self) -> nomifun_common::AgentType {
|
||||
nomifun_common::AgentType::Acp
|
||||
}
|
||||
fn conversation_id(&self) -> &str {
|
||||
&self.conversation_id
|
||||
}
|
||||
fn workspace(&self) -> &str {
|
||||
"/tmp/test"
|
||||
}
|
||||
fn status(&self) -> Option<nomifun_common::ConversationStatus> {
|
||||
None
|
||||
}
|
||||
fn last_activity_at(&self) -> nomifun_common::TimestampMs {
|
||||
nomifun_common::now_ms()
|
||||
}
|
||||
fn subscribe(&self) -> tokio::sync::broadcast::Receiver<nomifun_ai_agent::AgentStreamEvent> {
|
||||
let (tx, _) = tokio::sync::broadcast::channel(1);
|
||||
tx.subscribe()
|
||||
}
|
||||
async fn send_message(
|
||||
&self,
|
||||
_data: nomifun_ai_agent::types::SendMessageData,
|
||||
) -> Result<(), nomifun_ai_agent::AgentSendError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn cancel(&self) -> Result<(), nomifun_common::AppError> {
|
||||
Ok(())
|
||||
}
|
||||
fn kill(&self, _reason: Option<nomifun_common::AgentKillReason>) -> Result<(), nomifun_common::AppError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl IMockAgent for NoopMockAgent {}
|
||||
|
||||
pub async fn body_json(resp: axum::response::Response) -> serde_json::Value {
|
||||
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
|
||||
serde_json::from_slice(&bytes).unwrap()
|
||||
}
|
||||
|
||||
pub fn extract_csrf_token(resp: &axum::response::Response) -> Option<String> {
|
||||
resp.headers()
|
||||
.get_all(header::SET_COOKIE)
|
||||
.iter()
|
||||
.filter_map(|v| v.to_str().ok())
|
||||
.find(|s| s.starts_with("nomifun-csrf-token="))
|
||||
.map(|s| {
|
||||
s.strip_prefix("nomifun-csrf-token=")
|
||||
.unwrap()
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_owned()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_request(uri: &str) -> Request<Body> {
|
||||
Request::builder().method("GET").uri(uri).body(Body::empty()).unwrap()
|
||||
}
|
||||
|
||||
pub fn get_with_token(uri: &str, token: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(uri)
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn json_with_token(method_str: &str, uri: &str, body: serde_json::Value, token: &str, csrf: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method(method_str)
|
||||
.uri(uri)
|
||||
.header("content-type", "application/json")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("x-csrf-token", csrf)
|
||||
.header("cookie", format!("nomifun-csrf-token={csrf}"))
|
||||
.body(Body::from(serde_json::to_vec(&body).unwrap()))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn delete_with_token(uri: &str, token: &str, csrf: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("DELETE")
|
||||
.uri(uri)
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("x-csrf-token", csrf)
|
||||
.header("cookie", format!("nomifun-csrf-token={csrf}"))
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Set up a user and login, returning (session_token, csrf_token).
|
||||
///
|
||||
/// The seeded `system_default_user` row already uses `username = "admin"`; if
|
||||
/// the test asks for that username, overwrite the seed row's empty credentials
|
||||
/// in place instead of trying to INSERT a duplicate.
|
||||
pub async fn setup_and_login(
|
||||
app: &mut axum::Router,
|
||||
services: &AppServices,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> (String, String) {
|
||||
let hash = nomifun_auth::hash_password(password).unwrap();
|
||||
if username == "admin" {
|
||||
services
|
||||
.user_repo
|
||||
.set_system_user_credentials(username, &hash)
|
||||
.await
|
||||
.unwrap();
|
||||
} else {
|
||||
services.user_repo.create_user(username, &hash).await.unwrap();
|
||||
}
|
||||
|
||||
let resp = app.clone().oneshot(get_request("/api/auth/status")).await.unwrap();
|
||||
let csrf = extract_csrf_token(&resp).expect("CSRF cookie should be set");
|
||||
|
||||
let body = format!(r#"{{"username":"{username}","password":"{password}"}}"#);
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/login")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(body))
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK, "login should succeed");
|
||||
|
||||
let json = body_json(resp).await;
|
||||
let token = json["token"].as_str().unwrap().to_owned();
|
||||
|
||||
(token, csrf)
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
//! E2E tests for the multi-companion REST surface: `/api/companion/companions*` CRUD and the
|
||||
//! per-companion companion thread routes (T2.1).
|
||||
//!
|
||||
//! The companion roster persists on disk under the shared test data dir, so the
|
||||
//! assertions are id-scoped (find-by-id / 404-after-delete) and never assume
|
||||
//! an empty roster or absolute counts.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use common::{body_json, build_app, delete_with_token, get_with_token, json_with_token, setup_and_login};
|
||||
|
||||
/// POST /api/companion/companions and return the created profile JSON (asserts 201).
|
||||
async fn create_companion(
|
||||
app: &axum::Router,
|
||||
token: &str,
|
||||
csrf: &str,
|
||||
name: &str,
|
||||
character: &str,
|
||||
) -> serde_json::Value {
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/companion/companions",
|
||||
json!({ "name": name, "character": character }),
|
||||
token,
|
||||
csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
json["data"].clone()
|
||||
}
|
||||
|
||||
// ── companions CRUD ─────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn companions_crud_happy_path() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// Create.
|
||||
let created = create_companion(&app, &token, &csrf, "毛球", "ink").await;
|
||||
let id = created["id"].as_str().unwrap().to_owned();
|
||||
assert!(id.starts_with("companion_"));
|
||||
assert_eq!(created["name"], "毛球");
|
||||
assert_eq!(created["character"], "ink");
|
||||
|
||||
// List: profile fields flattened + embedded status.
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/companion/companions", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let list = body_json(resp).await;
|
||||
let entry = list["data"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|p| p["id"] == id.as_str())
|
||||
.expect("created companion should appear in the list")
|
||||
.clone();
|
||||
assert_eq!(entry["name"], "毛球");
|
||||
assert_eq!(entry["status"]["companion_id"], id.as_str());
|
||||
assert!(entry["status"]["level"].as_i64().unwrap() >= 1);
|
||||
|
||||
// Detail: same flattened shape.
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token(&format!("/api/companion/companions/{id}"), &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let detail = body_json(resp).await;
|
||||
assert_eq!(detail["data"]["id"], id.as_str());
|
||||
assert_eq!(detail["data"]["character"], "ink");
|
||||
assert_eq!(detail["data"]["status"]["companion_id"], id.as_str());
|
||||
|
||||
// RFC 7396 patch: rename + nested appearance merge.
|
||||
let req = json_with_token(
|
||||
"PATCH",
|
||||
&format!("/api/companion/companions/{id}"),
|
||||
json!({ "name": "新名", "appearance": { "companion_enabled": true } }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let patched = body_json(resp).await;
|
||||
assert_eq!(patched["data"]["id"], id.as_str());
|
||||
assert_eq!(patched["data"]["name"], "新名");
|
||||
assert_eq!(patched["data"]["appearance"]["companion_enabled"], true);
|
||||
// Untouched field survives the merge.
|
||||
assert_eq!(patched["data"]["character"], "ink");
|
||||
|
||||
// Per-companion status endpoint.
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token(&format!("/api/companion/companions/{id}/status"), &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let status = body_json(resp).await;
|
||||
assert_eq!(status["data"]["companion_id"], id.as_str());
|
||||
|
||||
// Delete: 204 and the companion is gone.
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(delete_with_token(&format!("/api/companion/companions/{id}"), &token, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token(&format!("/api/companion/companions/{id}"), &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn companions_unknown_id_is_404_and_bad_name_is_400() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// 404 on every per-companion verb for an unknown id.
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/companion/companions/companion_missing", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/companion/companions/companion_missing/status", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
let req = json_with_token(
|
||||
"PATCH",
|
||||
"/api/companion/companions/companion_missing",
|
||||
json!({ "name": "x" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(delete_with_token("/api/companion/companions/companion_missing", &token, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
// 400 on invalid names (service-level validation).
|
||||
let req = json_with_token("POST", "/api/companion/companions", json!({ "name": " " }), &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/companion/companions",
|
||||
json!({ "name": "x".repeat(41) }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// ── per-companion companion threads ─────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn companion_single_session_happy_path() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let companion = create_companion(&app, &token, &csrf, "甲", "ink").await;
|
||||
let id = companion["id"].as_str().unwrap().to_owned();
|
||||
|
||||
// Without a configured model the companion cannot open its session (400).
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
&format!("/api/companion/companions/{id}/companion/threads"),
|
||||
json!({}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
// Configure the model. The work-partner is a SINGLE-session model now:
|
||||
// configuring the model auto-ensures the one companion session.
|
||||
let req = json_with_token(
|
||||
"PATCH",
|
||||
&format!("/api/companion/companions/{id}"),
|
||||
json!({ "model": { "provider_id": "prov_test", "model": "test-model" } }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// POST create-thread is an idempotent ensure of that single session; it
|
||||
// returns the conversation bound to this companion.
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
&format!("/api/companion/companions/{id}/companion/threads"),
|
||||
json!({ "title": "第一聊" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let thread = body_json(resp).await;
|
||||
let conv = thread["data"]["conversation_id"].as_str().unwrap().to_owned();
|
||||
assert!(!conv.is_empty());
|
||||
assert_eq!(thread["data"]["companion_id"], id.as_str());
|
||||
|
||||
// GET active points at that same single session.
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token(&format!("/api/companion/companions/{id}/companion/active"), &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let active = body_json(resp).await;
|
||||
assert_eq!(active["data"]["conversation_id"], conv.as_str());
|
||||
|
||||
// Re-ensuring is idempotent — the same conversation, never a second one.
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
&format!("/api/companion/companions/{id}/companion/threads"),
|
||||
json!({ "title": "忽略" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let again = body_json(resp).await;
|
||||
assert_eq!(again["data"]["conversation_id"], conv.as_str());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn companion_thread_unknown_companion_404_and_no_model_400() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// Unknown companion: the single-session surface 404s (existence-gated)
|
||||
// instead of reading as "no active session".
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/companion/companions/companion_missing/companion/active", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/companion/companions/companion_missing/companion/threads",
|
||||
json!({}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
// A known companion without a configured model cannot open its session (400).
|
||||
let b = create_companion(&app, &token, &csrf, "乙", "boo").await;
|
||||
let b_id = b["id"].as_str().unwrap().to_owned();
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
&format!("/api/companion/companions/{b_id}/companion/threads"),
|
||||
json!({}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
//! E2E tests for Bedrock test-connection endpoint.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use common::{body_json, build_app, json_with_token, setup_and_login};
|
||||
|
||||
// ── 8.1 Bedrock Connection Test ─────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_1_bedrock_missing_config() {
|
||||
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("POST", "/api/bedrock/test-connection", json!({}), &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_1_bedrock_missing_region() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/bedrock/test-connection",
|
||||
json!({
|
||||
"bedrock_config": {
|
||||
"auth_method": "accessKey",
|
||||
"region": "",
|
||||
"access_key_id": "AKIAIOSFODNN7",
|
||||
"secret_access_key": "wJalrXUtnFEMI"
|
||||
}
|
||||
}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], false);
|
||||
assert!(json["error"].as_str().unwrap().contains("region"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_1_bedrock_access_key_missing_key_id() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/bedrock/test-connection",
|
||||
json!({
|
||||
"bedrock_config": {
|
||||
"auth_method": "accessKey",
|
||||
"region": "us-east-1",
|
||||
"secret_access_key": "wJalrXUtnFEMI"
|
||||
}
|
||||
}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["error"].as_str().unwrap().contains("accessKeyId"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_1_bedrock_access_key_missing_secret() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/bedrock/test-connection",
|
||||
json!({
|
||||
"bedrock_config": {
|
||||
"auth_method": "accessKey",
|
||||
"region": "us-east-1",
|
||||
"access_key_id": "AKIAIOSFODNN7"
|
||||
}
|
||||
}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["error"].as_str().unwrap().contains("secretAccessKey"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_1_bedrock_profile_missing() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/bedrock/test-connection",
|
||||
json!({
|
||||
"bedrock_config": {
|
||||
"auth_method": "profile",
|
||||
"region": "us-east-1"
|
||||
}
|
||||
}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["error"].as_str().unwrap().contains("profile"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_1_bedrock_unauthenticated() {
|
||||
let (app, _services) = build_app().await;
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/bedrock/test-connection")
|
||||
.header("content-type", "application/json")
|
||||
.body(axum::body::Body::from(
|
||||
serde_json::to_vec(&json!({
|
||||
"bedrock_config": {
|
||||
"auth_method": "accessKey",
|
||||
"region": "us-east-1",
|
||||
"access_key_id": "AKIA",
|
||||
"secret_access_key": "secret"
|
||||
}
|
||||
}))
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
// CSRF middleware returns 403 for POST without CSRF token
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_1_bedrock_invalid_credentials() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/bedrock/test-connection",
|
||||
json!({
|
||||
"bedrock_config": {
|
||||
"auth_method": "accessKey",
|
||||
"region": "us-east-1",
|
||||
"access_key_id": "AKIAFAKEKEY1234567890",
|
||||
"secret_access_key": "fakesecretkey1234567890abcdefghijklmnopq"
|
||||
}
|
||||
}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
// Fake credentials fail at the AWS API level → 422 Unprocessable Entity
|
||||
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], false);
|
||||
assert!(json["error"].as_str().unwrap().contains("Bedrock credentials invalid"));
|
||||
}
|
||||
@@ -0,0 +1,928 @@
|
||||
//! E2E tests for conversation CRUD, clone, reset, associated, and auth protection.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use common::{body_json, build_app, delete_with_token, get_request, get_with_token, json_with_token, setup_and_login};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
fn create_body(name: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "acp",
|
||||
"name": name,
|
||||
"extra": { "workspace": "/project" }
|
||||
})
|
||||
}
|
||||
|
||||
fn create_body_with_extra(name: &str, extra: serde_json::Value) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "acp",
|
||||
"name": name,
|
||||
"extra": extra
|
||||
})
|
||||
}
|
||||
|
||||
// ── T1: Create ────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t1_1_create_conversation_success() {
|
||||
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("POST", "/api/conversations", create_body("Code Review"), &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
let data = &json["data"];
|
||||
assert_eq!(data["name"], "Code Review");
|
||||
assert_eq!(data["type"], "acp");
|
||||
assert_eq!(data["status"], "pending");
|
||||
assert_eq!(data["source"], "nomifun");
|
||||
assert_eq!(data["pinned"], false);
|
||||
assert!(data["id"].as_i64().is_some());
|
||||
assert!(data["created_at"].as_i64().is_some());
|
||||
assert!(data["modified_at"].as_i64().is_some());
|
||||
assert_eq!(data["extra"]["workspace"], "/project");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t1_2_create_various_agent_types() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let types = ["acp", "openclaw-gateway", "nanobot", "remote"];
|
||||
for agent_type in types {
|
||||
let body = json!({
|
||||
"type": agent_type,
|
||||
"extra": {}
|
||||
});
|
||||
let req = json_with_token("POST", "/api/conversations", body, &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED, "type={agent_type}");
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["type"], agent_type);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t1_3_create_with_optional_fields() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let body = json!({
|
||||
"type": "acp",
|
||||
"name": "Telegram Bot",
|
||||
"source": "telegram",
|
||||
"channel_chat_id": "user:123",
|
||||
"extra": {}
|
||||
});
|
||||
let req = json_with_token("POST", "/api/conversations", body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["source"], "telegram");
|
||||
assert_eq!(json["data"]["channel_chat_id"], "user:123");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t1_4_create_missing_required_field() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// Missing type
|
||||
let body = json!({
|
||||
"model": { "provider_id": "p1", "model": "m1" },
|
||||
"extra": {}
|
||||
});
|
||||
let req = json_with_token("POST", "/api/conversations", body, &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
// model is optional — omitting it should succeed
|
||||
let body = json!({ "type": "acp", "extra": {} });
|
||||
let req = json_with_token("POST", "/api/conversations", body, &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
|
||||
// Missing extra
|
||||
let body = json!({
|
||||
"type": "nomi",
|
||||
"model": { "provider_id": "p1", "model": "m1" }
|
||||
});
|
||||
let req = json_with_token("POST", "/api/conversations", body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t1_5_create_invalid_type() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let body = json!({
|
||||
"type": "invalid_type",
|
||||
"model": { "provider_id": "p1", "model": "m1" },
|
||||
"extra": {}
|
||||
});
|
||||
let req = json_with_token("POST", "/api/conversations", body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t1_5b_create_accepts_interior_whitespace_and_rejects_edge_whitespace() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// Interior whitespace ("Application Support" on macOS, "my project") is a
|
||||
// normal path and must be accepted.
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let workspace = temp.path().join("Application Support").join("my project");
|
||||
std::fs::create_dir_all(&workspace).unwrap();
|
||||
|
||||
let body = json!({
|
||||
"type": "acp",
|
||||
"extra": {
|
||||
"workspace": workspace.to_string_lossy()
|
||||
}
|
||||
});
|
||||
let req = json_with_token("POST", "/api/conversations", body, &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["extra"]["workspace"], workspace.to_string_lossy().as_ref());
|
||||
|
||||
// A directory name that ends with whitespace is pathological (Win32
|
||||
// strips trailing spaces on lookup) and stays rejected.
|
||||
let edge_workspace = format!("{} ", temp.path().join("repo").to_string_lossy());
|
||||
let body = json!({
|
||||
"type": "acp",
|
||||
"extra": {
|
||||
"workspace": edge_workspace
|
||||
}
|
||||
});
|
||||
let req = json_with_token("POST", "/api/conversations", body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["code"], "WORKSPACE_PATH_EDGE_WHITESPACE_UNSUPPORTED");
|
||||
assert!(
|
||||
json["error"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("begins or ends with whitespace"),
|
||||
"unexpected error payload: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t1_6_create_requires_auth() {
|
||||
let (app, _services) = build_app().await;
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/conversations")
|
||||
.header("content-type", "application/json")
|
||||
.body(axum::body::Body::from(
|
||||
serde_json::to_vec(&create_body("test")).unwrap(),
|
||||
))
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// ── T2: List ──────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_1_list_empty() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let resp = app.oneshot(get_with_token("/api/conversations", &token)).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["items"].as_array().unwrap().len(), 0);
|
||||
assert_eq!(json["data"]["total"], 0);
|
||||
assert_eq!(json["data"]["has_more"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_2_list_basic() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
for i in 0..3 {
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/conversations",
|
||||
create_body(&format!("Conv {i}")),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
app.clone().oneshot(req).await.unwrap();
|
||||
}
|
||||
|
||||
let resp = app.oneshot(get_with_token("/api/conversations", &token)).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["items"].as_array().unwrap().len(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_3_list_cursor_pagination() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
for i in 0..5 {
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/conversations",
|
||||
create_body(&format!("Conv {i}")),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
app.clone().oneshot(req).await.unwrap();
|
||||
}
|
||||
|
||||
// First page: limit=2
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/conversations?limit=2", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
let items = json["data"]["items"].as_array().unwrap();
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(json["data"]["has_more"], true);
|
||||
|
||||
// Second page using cursor
|
||||
let cursor = items.last().unwrap()["id"].as_i64().unwrap();
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token(
|
||||
&format!("/api/conversations?limit=2&cursor={cursor}"),
|
||||
&token,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
let items2 = json["data"]["items"].as_array().unwrap();
|
||||
assert_eq!(items2.len(), 2);
|
||||
assert_eq!(json["data"]["has_more"], true);
|
||||
|
||||
// Third page
|
||||
let cursor2 = items2.last().unwrap()["id"].as_i64().unwrap();
|
||||
let resp = app
|
||||
.oneshot(get_with_token(
|
||||
&format!("/api/conversations?limit=2&cursor={cursor2}"),
|
||||
&token,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
let items3 = json["data"]["items"].as_array().unwrap();
|
||||
assert_eq!(items3.len(), 1);
|
||||
assert_eq!(json["data"]["has_more"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_4_list_source_filter() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// Create 2 nomifun + 1 telegram
|
||||
for _ in 0..2 {
|
||||
let req = json_with_token("POST", "/api/conversations", create_body("Nomi Conv"), &token, &csrf);
|
||||
app.clone().oneshot(req).await.unwrap();
|
||||
}
|
||||
|
||||
let tg_body = json!({
|
||||
"type": "acp",
|
||||
"name": "TG Conv",
|
||||
"source": "telegram",
|
||||
"extra": {}
|
||||
});
|
||||
let req = json_with_token("POST", "/api/conversations", tg_body, &token, &csrf);
|
||||
app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_token("/api/conversations?source=telegram", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
let items = json["data"]["items"].as_array().unwrap();
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0]["source"], "telegram");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_5_list_pinned_filter() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// Create 2 conversations
|
||||
let req = json_with_token("POST", "/api/conversations", create_body("Unpinned"), &token, &csrf);
|
||||
app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
let req = json_with_token("POST", "/api/conversations", create_body("Will Pin"), &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
let pinned_id = json["data"]["id"].as_i64().unwrap();
|
||||
|
||||
// Pin one
|
||||
let req = json_with_token(
|
||||
"PATCH",
|
||||
&format!("/api/conversations/{pinned_id}"),
|
||||
json!({"pinned": true}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_token("/api/conversations?pinned=true", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
let items = json["data"]["items"].as_array().unwrap();
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0]["pinned"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_6_list_requires_auth() {
|
||||
let (app, _services) = build_app().await;
|
||||
let resp = app.oneshot(get_request("/api/conversations")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// ── T3: Get ───────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t3_1_get_existing() {
|
||||
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("POST", "/api/conversations", create_body("My Conv"), &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
let id = json["data"]["id"].as_i64().unwrap();
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_token(&format!("/api/conversations/{id}"), &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["id"], id);
|
||||
assert_eq!(json["data"]["name"], "My Conv");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t3_2_get_not_found() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_token("/api/conversations/non-existent-id", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t3_3_get_requires_auth() {
|
||||
let (app, _services) = build_app().await;
|
||||
let resp = app.oneshot(get_request("/api/conversations/some-id")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// ── T4: Update ────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t4_1_update_name() {
|
||||
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("POST", "/api/conversations", create_body("Original"), &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
let id = json["data"]["id"].as_i64().unwrap();
|
||||
let original_modified = json["data"]["modified_at"].as_i64().unwrap();
|
||||
|
||||
let req = json_with_token(
|
||||
"PATCH",
|
||||
&format!("/api/conversations/{id}"),
|
||||
json!({"name": "Updated"}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["name"], "Updated");
|
||||
assert!(json["data"]["modified_at"].as_i64().unwrap() >= original_modified);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t4_2_update_pin_and_unpin() {
|
||||
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("POST", "/api/conversations", create_body("Pin Test"), &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
let id = json["data"]["id"].as_i64().unwrap();
|
||||
|
||||
// Pin
|
||||
let req = json_with_token(
|
||||
"PATCH",
|
||||
&format!("/api/conversations/{id}"),
|
||||
json!({"pinned": true}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["pinned"], true);
|
||||
assert!(json["data"]["pinned_at"].as_i64().is_some());
|
||||
|
||||
// Unpin
|
||||
let req = json_with_token(
|
||||
"PATCH",
|
||||
&format!("/api/conversations/{id}"),
|
||||
json!({"pinned": false}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["pinned"], false);
|
||||
assert!(json["data"]["pinned_at"].is_null());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t4_3_update_extra_merge() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let body = create_body_with_extra(
|
||||
"Merge Test",
|
||||
json!({"workspace": "/old", "context_file_name": "ctx.md"}),
|
||||
);
|
||||
let req = json_with_token("POST", "/api/conversations", body, &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
let id = json["data"]["id"].as_i64().unwrap();
|
||||
|
||||
// Merge update: change workspace, keep contextFileName
|
||||
let req = json_with_token(
|
||||
"PATCH",
|
||||
&format!("/api/conversations/{id}"),
|
||||
json!({"extra": {"workspace": "/new"}}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["extra"]["workspace"], "/new");
|
||||
assert_eq!(json["data"]["extra"]["context_file_name"], "ctx.md");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t4_4_update_model() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// nomi — only type that allows top-level model updates
|
||||
let create = json!({
|
||||
"type": "nomi",
|
||||
"name": "Model Test",
|
||||
"model": { "provider_id": "p1", "model": "m1" },
|
||||
"extra": {}
|
||||
});
|
||||
let req = json_with_token("POST", "/api/conversations", create, &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
let id = json["data"]["id"].as_i64().unwrap();
|
||||
|
||||
let req = json_with_token(
|
||||
"PATCH",
|
||||
&format!("/api/conversations/{id}"),
|
||||
json!({"model": {"provider_id": "p2", "model": "new-model"}}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["model"]["provider_id"], "p2");
|
||||
assert_eq!(json["data"]["model"]["model"], "new-model");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t4_5_update_not_found() {
|
||||
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/conversations/non-existent-id",
|
||||
json!({"name": "X"}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t4_6_update_requires_auth() {
|
||||
let (app, _services) = build_app().await;
|
||||
let resp = app.oneshot(get_request("/api/conversations/some-id")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// ── T5: Delete ────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t5_1_delete_conversation() {
|
||||
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("POST", "/api/conversations", create_body("To Delete"), &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
let id = json["data"]["id"].as_i64().unwrap();
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(delete_with_token(&format!("/api/conversations/{id}"), &token, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// Verify it's gone
|
||||
let resp = app
|
||||
.oneshot(get_with_token(&format!("/api/conversations/{id}"), &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t5_2_delete_not_found() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(delete_with_token("/api/conversations/non-existent-id", &token, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t5_3_delete_requires_auth() {
|
||||
let (app, _services) = build_app().await;
|
||||
let req = axum::http::Request::builder()
|
||||
.method("DELETE")
|
||||
.uri("/api/conversations/some-id")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// ── T6: Clone ─────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t6_2_clone_without_source() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let clone_body = json!({
|
||||
"conversation": {
|
||||
"type": "acp",
|
||||
"name": "Fresh Clone",
|
||||
"extra": {}
|
||||
}
|
||||
});
|
||||
let req = json_with_token("POST", "/api/conversations/clone", clone_body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["name"], "Fresh Clone");
|
||||
assert_eq!(json["data"]["type"], "acp");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t6_4_clone_requires_auth() {
|
||||
let (app, _services) = build_app().await;
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/conversations/clone")
|
||||
.header("content-type", "application/json")
|
||||
.body(axum::body::Body::from(b"{}".to_vec()))
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// ── T7: Reset ─────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t7_1_reset_conversation() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// Create conversation
|
||||
let req = json_with_token("POST", "/api/conversations", create_body("Reset Test"), &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
let id = json["data"]["id"].as_i64().unwrap();
|
||||
|
||||
// Insert a message directly via repo
|
||||
let repo = nomifun_db::SqliteConversationRepository::new(services.database.pool().clone());
|
||||
let msg = nomifun_db::models::MessageRow {
|
||||
id: "msg-1".into(),
|
||||
conversation_id: id.clone(),
|
||||
msg_id: None,
|
||||
r#type: "text".into(),
|
||||
content: r#"{"content":"hello"}"#.into(),
|
||||
position: None,
|
||||
status: None,
|
||||
hidden: false,
|
||||
created_at: 1000,
|
||||
};
|
||||
nomifun_db::IConversationRepository::insert_message(&repo, &msg)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Reset
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
&format!("/api/conversations/{id}/reset"),
|
||||
json!({}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// Verify messages cleared
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token(&format!("/api/conversations/{id}/messages"), &token))
|
||||
.await
|
||||
.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["items"].as_array().unwrap().len(), 0);
|
||||
|
||||
// Verify status is pending
|
||||
let resp = app
|
||||
.oneshot(get_with_token(&format!("/api/conversations/{id}"), &token))
|
||||
.await
|
||||
.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["status"], "pending");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t7_2_reset_not_found() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/conversations/non-existent-id/reset",
|
||||
json!({}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t7_3_reset_requires_auth() {
|
||||
let (app, _services) = build_app().await;
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/conversations/some-id/reset")
|
||||
.header("content-type", "application/json")
|
||||
.body(axum::body::Body::from(b"{}".to_vec()))
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// ── T10: Associated ───────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t10_1_associated_same_workspace() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// Create 3 conversations: 2 same workspace, 1 different
|
||||
let body1 = create_body_with_extra("Conv A", json!({"workspace": "/same"}));
|
||||
let req = json_with_token("POST", "/api/conversations", body1, &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
let id_a = json["data"]["id"].as_i64().unwrap();
|
||||
|
||||
let body2 = create_body_with_extra("Conv B", json!({"workspace": "/same"}));
|
||||
let req = json_with_token("POST", "/api/conversations", body2, &token, &csrf);
|
||||
app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
let body3 = create_body_with_extra("Conv C", json!({"workspace": "/other"}));
|
||||
let req = json_with_token("POST", "/api/conversations", body3, &token, &csrf);
|
||||
app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_token(&format!("/api/conversations/{id_a}/associated"), &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
let items = json["data"].as_array().unwrap();
|
||||
assert_eq!(items.len(), 1); // only Conv B, not self or Conv C
|
||||
assert_eq!(items[0]["extra"]["workspace"], "/same");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t10_2_associated_none() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let body = create_body_with_extra("Unique", json!({"workspace": "/unique"}));
|
||||
let req = json_with_token("POST", "/api/conversations", body, &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
let id = json["data"]["id"].as_i64().unwrap();
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_token(&format!("/api/conversations/{id}/associated"), &token))
|
||||
.await
|
||||
.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t10_3_associated_not_found() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_token("/api/conversations/non-existent-id/associated", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t10_4_associated_requires_auth() {
|
||||
let (app, _services) = build_app().await;
|
||||
let resp = app
|
||||
.oneshot(get_request("/api/conversations/some-id/associated"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// ── T12: Boundary scenarios ───────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t12_1_long_name() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let long_name = "A".repeat(1000);
|
||||
let req = json_with_token("POST", "/api/conversations", create_body(&long_name), &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["name"].as_str().unwrap().len(), 1000);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t12_2_large_nested_extra() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let big_extra = json!({
|
||||
"workspace": "/project",
|
||||
"nested": {
|
||||
"level1": {
|
||||
"level2": {
|
||||
"level3": { "deep": true }
|
||||
}
|
||||
}
|
||||
},
|
||||
"array": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
});
|
||||
let body = create_body_with_extra("Big Extra", big_extra.clone());
|
||||
let req = json_with_token("POST", "/api/conversations", body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(
|
||||
json["data"]["extra"]["nested"]["level1"]["level2"]["level3"]["deep"],
|
||||
true
|
||||
);
|
||||
assert_eq!(json["data"]["extra"]["array"].as_array().unwrap().len(), 10);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t12_3_concurrent_creates() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let mut ids = Vec::new();
|
||||
for i in 0..10 {
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/conversations",
|
||||
create_body(&format!("Concurrent {i}")),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
let json = body_json(resp).await;
|
||||
ids.push(json["data"]["id"].as_i64().unwrap());
|
||||
}
|
||||
|
||||
// All IDs should be unique
|
||||
let unique: std::collections::HashSet<_> = ids.iter().collect();
|
||||
assert_eq!(unique.len(), 10);
|
||||
}
|
||||
|
||||
// ── Full lifecycle ────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_conversation_lifecycle() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// Create
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/conversations",
|
||||
create_body("Lifecycle Test"),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
let json = body_json(resp).await;
|
||||
let id = json["data"]["id"].as_i64().unwrap();
|
||||
assert_eq!(json["data"]["status"], "pending");
|
||||
|
||||
// Read
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token(&format!("/api/conversations/{id}"), &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// Update
|
||||
let req = json_with_token(
|
||||
"PATCH",
|
||||
&format!("/api/conversations/{id}"),
|
||||
json!({"name": "Updated Lifecycle"}),
|
||||
&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"]["name"], "Updated Lifecycle");
|
||||
|
||||
// Delete
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(delete_with_token(&format!("/api/conversations/{id}"), &token, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// Verify gone
|
||||
let resp = app
|
||||
.oneshot(get_with_token(&format!("/api/conversations/{id}"), &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
@@ -0,0 +1,812 @@
|
||||
//! E2E tests for cron job HTTP endpoints.
|
||||
//!
|
||||
//! Covers test-plan items: CJ-1..CJ-12, SK-1..SK-6, SC-3..SC-8, AU-1..AU-2,
|
||||
//! RN-1..RN-2.
|
||||
//! Items requiring real AI execution (RN-1, EV-*, SR-*, OC-*, CD-*) are tested
|
||||
//! at the service integration level in `nomifun-cron/tests/service_integration.rs`.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use nomifun_db::{ICronRepository, SqliteCronRepository};
|
||||
|
||||
use common::{body_json, build_app, delete_with_token, get_request, get_with_token, json_with_token, setup_and_login};
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
fn create_job_body(name: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"name": name,
|
||||
"schedule": { "kind": "every", "every_ms": 60000, "description": "every minute" },
|
||||
"message": "test message",
|
||||
"conversation_id": 1,
|
||||
"conversation_title": "Test Conv",
|
||||
"agent_type": "acp",
|
||||
"created_by": "user"
|
||||
})
|
||||
}
|
||||
|
||||
fn create_at_job_body(name: &str, at_ms: i64) -> serde_json::Value {
|
||||
json!({
|
||||
"name": name,
|
||||
"schedule": { "kind": "at", "at_ms": at_ms, "description": "once" },
|
||||
"message": "at message",
|
||||
"conversation_id": 1,
|
||||
"agent_type": "acp",
|
||||
"created_by": "user"
|
||||
})
|
||||
}
|
||||
|
||||
fn create_cron_job_body(name: &str, expr: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"name": name,
|
||||
"schedule": { "kind": "cron", "expr": expr },
|
||||
"message": "cron message",
|
||||
"conversation_id": 1,
|
||||
"agent_type": "acp",
|
||||
"created_by": "user"
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_job(app: &mut axum::Router, token: &str, csrf: &str, body: serde_json::Value) -> serde_json::Value {
|
||||
let req = json_with_token("POST", "/api/cron/jobs", body, token, csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
json["data"].clone()
|
||||
}
|
||||
|
||||
/// Seed a minimal `conversations` parent row so a cron job carrying this
|
||||
/// `conversation_id` satisfies the `cron_jobs.conversation_id -> conversations`
|
||||
/// foreign key. `system_default_user` is already seeded by the migration.
|
||||
async fn seed_conversation(services: &nomifun_app::AppServices, id: i64) {
|
||||
sqlx::query(
|
||||
"INSERT INTO conversations (id, user_id, name, type, created_at, updated_at) \
|
||||
VALUES (?, 'system_default_user', 'Seeded Conv', 'acp', 0, 0)",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(services.database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// ── AU-1/AU-2: Unauthenticated requests ─────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn au1_unauthenticated_list_returns_403() {
|
||||
let (app, _services) = build_app().await;
|
||||
let req = get_request("/api/cron/jobs");
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert!(
|
||||
resp.status() == StatusCode::UNAUTHORIZED || resp.status() == StatusCode::FORBIDDEN,
|
||||
"expected 401 or 403, got {}",
|
||||
resp.status()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn au2_unauthenticated_all_endpoints() {
|
||||
let (app, _services) = build_app().await;
|
||||
|
||||
let endpoints = vec![
|
||||
("GET", "/api/cron/jobs"),
|
||||
("GET", "/api/cron/jobs/cron_test"),
|
||||
("GET", "/api/cron/jobs/cron_test/skill"),
|
||||
("DELETE", "/api/cron/jobs/cron_test/skill"),
|
||||
];
|
||||
|
||||
for (method, uri) in endpoints {
|
||||
let req = axum::http::Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert!(
|
||||
resp.status() == StatusCode::UNAUTHORIZED || resp.status() == StatusCode::FORBIDDEN,
|
||||
"{method} {uri} expected 401/403, got {}",
|
||||
resp.status()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── CJ-1: Create cron job ───────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn cj1_create_cron_job() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
seed_conversation(&services, 1).await;
|
||||
let data = create_job(&mut app, &token, &csrf, create_job_body("Daily Report")).await;
|
||||
|
||||
assert!(data["id"].as_str().unwrap().starts_with("cron_"));
|
||||
assert_eq!(data["name"], "Daily Report");
|
||||
assert_eq!(data["enabled"], true);
|
||||
assert!(data["state"]["next_run_at_ms"].as_i64().is_some());
|
||||
assert_eq!(data["state"]["run_count"], 0);
|
||||
assert_eq!(data["target"]["payload"]["kind"], "message");
|
||||
assert_eq!(data["target"]["payload"]["text"], "test message");
|
||||
assert_eq!(data["metadata"]["conversation_id"], 1);
|
||||
assert_eq!(data["metadata"]["agent_type"], "acp");
|
||||
assert_eq!(data["metadata"]["created_by"], "user");
|
||||
}
|
||||
|
||||
// ── CJ-2: Create three schedule types ────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn cj2_create_three_schedule_types() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let now = nomifun_common::now_ms();
|
||||
|
||||
seed_conversation(&services, 1).await;
|
||||
let at = create_job(&mut app, &token, &csrf, create_at_job_body("At Job", now + 3_600_000)).await;
|
||||
assert_eq!(at["schedule"]["kind"], "at");
|
||||
assert!(at["state"]["next_run_at_ms"].as_i64().unwrap() > now);
|
||||
|
||||
let every = create_job(&mut app, &token, &csrf, create_job_body("Every Job")).await;
|
||||
assert_eq!(every["schedule"]["kind"], "every");
|
||||
let next = every["state"]["next_run_at_ms"].as_i64().unwrap();
|
||||
assert!((next - now - 60000).abs() < 3000);
|
||||
|
||||
let cron = create_job(
|
||||
&mut app,
|
||||
&token,
|
||||
&csrf,
|
||||
create_cron_job_body("Cron Job", "0 */5 * * * *"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(cron["schedule"]["kind"], "cron");
|
||||
assert!(cron["state"]["next_run_at_ms"].as_i64().unwrap() > now);
|
||||
}
|
||||
|
||||
// ── CJ-3: Create parameter validation ────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn cj3_create_missing_required_fields() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let invalid_bodies = vec![
|
||||
json!({"schedule": {"kind": "every", "every_ms": 60000}, "conversation_id": 1, "agent_type": "acp", "created_by": "user"}),
|
||||
json!({"name": "X", "conversation_id": 1, "agent_type": "acp", "created_by": "user"}),
|
||||
json!({"name": "X", "schedule": {"kind": "every", "every_ms": 60000}, "agent_type": "acp", "created_by": "user"}),
|
||||
json!({"name": "X", "schedule": {"kind": "every", "every_ms": 60000}, "conversation_id": 1, "created_by": "user"}),
|
||||
];
|
||||
|
||||
for body in invalid_bodies {
|
||||
let req = json_with_token("POST", "/api/cron/jobs", body, &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::BAD_REQUEST,
|
||||
"missing field should return 400"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cj3b_create_rejects_workspace_with_edge_whitespace_segment() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let body = json!({
|
||||
"name": "Whitespace Workspace",
|
||||
"schedule": { "kind": "every", "every_ms": 60000, "description": "every minute" },
|
||||
"message": "test message",
|
||||
"conversation_id": 0,
|
||||
"agent_type": "acp",
|
||||
"created_by": "user",
|
||||
"execution_mode": "new_conversation",
|
||||
"agent_config": {
|
||||
"backend": "acp",
|
||||
"name": "Cron Agent",
|
||||
"workspace": "/Users/zhoukai/Documents/Archive "
|
||||
}
|
||||
});
|
||||
|
||||
let req = json_with_token("POST", "/api/cron/jobs", body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["code"], "WORKSPACE_PATH_EDGE_WHITESPACE_UNSUPPORTED");
|
||||
assert!(
|
||||
json["error"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("begins or ends with whitespace")
|
||||
);
|
||||
}
|
||||
|
||||
// ── CJ-4: Get single job ────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn cj4_get_single_job() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
seed_conversation(&services, 1).await;
|
||||
let created = create_job(&mut app, &token, &csrf, create_job_body("Get Test")).await;
|
||||
let job_id = created["id"].as_str().unwrap();
|
||||
|
||||
let req = get_with_token(&format!("/api/cron/jobs/{job_id}"), &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["id"], job_id);
|
||||
assert_eq!(json["data"]["name"], "Get Test");
|
||||
}
|
||||
|
||||
// ── CJ-5: Get nonexistent job ────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn cj5_get_nonexistent() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = get_with_token("/api/cron/jobs/cron_nonexistent", &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cj5b_run_now_legacy_workspace_uses_runtime_edge_whitespace_code() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let cron_repo = SqliteCronRepository::new(services.database.pool().clone());
|
||||
let now = nomifun_common::now_ms();
|
||||
|
||||
cron_repo
|
||||
.insert(&nomifun_db::models::CronJobRow {
|
||||
id: "cron_whitespace_workspace".into(),
|
||||
name: "Legacy Workspace".into(),
|
||||
enabled: true,
|
||||
schedule_kind: "every".into(),
|
||||
schedule_value: "60000".into(),
|
||||
schedule_tz: None,
|
||||
schedule_description: Some("every minute".into()),
|
||||
payload_message: "test message".into(),
|
||||
execution_mode: "new_conversation".into(),
|
||||
agent_config: Some(
|
||||
json!({
|
||||
"backend": "acp",
|
||||
"name": "Cron Agent",
|
||||
"workspace": "/Users/zhoukai/Documents/Archive "
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
conversation_id: None,
|
||||
conversation_title: None,
|
||||
agent_type: "acp".into(),
|
||||
created_by: "user".into(),
|
||||
skill_content: None,
|
||||
description: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
next_run_at: Some(now + 60_000),
|
||||
last_run_at: None,
|
||||
last_status: None,
|
||||
last_error: None,
|
||||
run_count: 0,
|
||||
retry_count: 0,
|
||||
max_retries: 3,
|
||||
target_kind: "agent".into(),
|
||||
terminal_mode: None,
|
||||
terminal_session_id: None,
|
||||
terminal_command: None,
|
||||
terminal_args: None,
|
||||
terminal_script: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/cron/jobs/cron_whitespace_workspace/run",
|
||||
json!({}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["code"], "WORKSPACE_PATH_EDGE_WHITESPACE_RUNTIME_UNSUPPORTED");
|
||||
assert!(
|
||||
json["error"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("begins or ends with whitespace")
|
||||
);
|
||||
assert_eq!(json["details"]["operation"], "runtime");
|
||||
}
|
||||
|
||||
// ── CJ-6: List all jobs ─────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn cj6_list_all_jobs() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
seed_conversation(&services, 1).await;
|
||||
for i in 0..3 {
|
||||
create_job(&mut app, &token, &csrf, create_job_body(&format!("Job {i}"))).await;
|
||||
}
|
||||
|
||||
let req = get_with_token("/api/cron/jobs", &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
let items = json["data"].as_array().unwrap();
|
||||
assert!(items.len() >= 3);
|
||||
}
|
||||
|
||||
// ── CJ-7: List by conversation ID ───────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn cj7_list_by_conversation() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
seed_conversation(&services, 2).await;
|
||||
seed_conversation(&services, 3).await;
|
||||
let mut body_a = create_job_body("Job A");
|
||||
body_a["conversation_id"] = json!(2);
|
||||
create_job(&mut app, &token, &csrf, body_a).await;
|
||||
|
||||
let mut body_b = create_job_body("Job B");
|
||||
body_b["conversation_id"] = json!(2);
|
||||
create_job(&mut app, &token, &csrf, body_b).await;
|
||||
|
||||
let mut body_c = create_job_body("Job C");
|
||||
body_c["conversation_id"] = json!(3);
|
||||
create_job(&mut app, &token, &csrf, body_c).await;
|
||||
|
||||
let req = get_with_token("/api/cron/jobs?conversation_id=2", &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
let items = json["data"].as_array().unwrap();
|
||||
assert_eq!(items.len(), 2);
|
||||
}
|
||||
|
||||
// ── CJ-8: Update job ────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn cj8_update_job() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
seed_conversation(&services, 1).await;
|
||||
let created = create_job(&mut app, &token, &csrf, create_job_body("Original")).await;
|
||||
let job_id = created["id"].as_str().unwrap();
|
||||
|
||||
let update_body = json!({"name": "Updated Name", "enabled": false});
|
||||
let req = json_with_token("PUT", &format!("/api/cron/jobs/{job_id}"), update_body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["name"], "Updated Name");
|
||||
assert_eq!(json["data"]["enabled"], false);
|
||||
assert!(
|
||||
json["data"]["metadata"]["updated_at"].as_i64().unwrap() >= created["metadata"]["created_at"].as_i64().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
// ── CJ-9: Update schedule type ──────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn cj9_update_schedule_type() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
seed_conversation(&services, 1).await;
|
||||
let created = create_job(&mut app, &token, &csrf, create_job_body("Schedule Change")).await;
|
||||
let job_id = created["id"].as_str().unwrap();
|
||||
|
||||
let update_body = json!({"schedule": {"kind": "cron", "expr": "0 */5 * * * *"}});
|
||||
let req = json_with_token("PUT", &format!("/api/cron/jobs/{job_id}"), update_body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["schedule"]["kind"], "cron");
|
||||
assert!(json["data"]["state"]["next_run_at_ms"].as_i64().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cj9b_update_schedule_preserves_existing_timezone_when_omitted() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
seed_conversation(&services, 1).await;
|
||||
let created = create_job(
|
||||
&mut app,
|
||||
&token,
|
||||
&csrf,
|
||||
json!({
|
||||
"name": "Schedule Change With Timezone",
|
||||
"schedule": { "kind": "cron", "expr": "0 0 9 * * *", "tz": "Asia/Shanghai" },
|
||||
"message": "cron message",
|
||||
"conversation_id": 1,
|
||||
"agent_type": "acp",
|
||||
"created_by": "user"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let job_id = created["id"].as_str().unwrap();
|
||||
|
||||
let update_body = json!({"schedule": {"kind": "cron", "expr": "0 30 9 * * *"}});
|
||||
let req = json_with_token("PUT", &format!("/api/cron/jobs/{job_id}"), update_body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["schedule"]["kind"], "cron");
|
||||
assert_eq!(json["data"]["schedule"]["expr"], "0 30 9 * * *");
|
||||
assert_eq!(json["data"]["schedule"]["tz"], "Asia/Shanghai");
|
||||
}
|
||||
|
||||
// ── CJ-10: Update nonexistent ────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn cj10_update_nonexistent() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let update_body = json!({"name": "X"});
|
||||
let req = json_with_token("PUT", "/api/cron/jobs/cron_nonexistent", update_body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// ── CJ-11: Delete job ───────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn cj11_delete_job() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
seed_conversation(&services, 1).await;
|
||||
let created = create_job(&mut app, &token, &csrf, create_job_body("To Delete")).await;
|
||||
let job_id = created["id"].as_str().unwrap();
|
||||
|
||||
let req = delete_with_token(&format!("/api/cron/jobs/{job_id}"), &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let req = get_with_token(&format!("/api/cron/jobs/{job_id}"), &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// ── CJ-12: Delete nonexistent ────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn cj12_delete_nonexistent() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = delete_with_token("/api/cron/jobs/cron_nonexistent", &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// ── RN-2: Run now nonexistent ────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn rn1_run_now_returns_conversation_id_for_new_conversation_job() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let create_conv_req = json_with_token(
|
||||
"POST",
|
||||
"/api/conversations",
|
||||
json!({
|
||||
"type": "acp",
|
||||
"name": "Run Now Source",
|
||||
"extra": { "workspace": "/project" }
|
||||
}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let create_conv_resp = app.clone().oneshot(create_conv_req).await.unwrap();
|
||||
assert_eq!(create_conv_resp.status(), StatusCode::CREATED);
|
||||
let created_conv = body_json(create_conv_resp).await;
|
||||
let conversation_id = created_conv["data"]["id"].as_i64().unwrap();
|
||||
|
||||
let mut body = create_job_body("Run Now Job");
|
||||
body["conversation_id"] = json!(conversation_id);
|
||||
let created = create_job(&mut app, &token, &csrf, body).await;
|
||||
let job_id = created["id"].as_str().unwrap();
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
&format!("/api/cron/jobs/{job_id}/run"),
|
||||
json!({}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body = body_json(resp).await;
|
||||
assert_eq!(body["data"]["conversation_id"], json!(conversation_id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rn2_run_now_nonexistent() {
|
||||
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("POST", "/api/cron/jobs/cron_nonexistent/run", json!({}), &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// ── SK-1: Save skill ────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn sk1_save_skill() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
seed_conversation(&services, 1).await;
|
||||
let created = create_job(&mut app, &token, &csrf, create_job_body("Skill Job")).await;
|
||||
let job_id = created["id"].as_str().unwrap();
|
||||
|
||||
let skill_body = json!({"content": "---\nname: test\ndescription: test skill\n---\nDo something"});
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
&format!("/api/cron/jobs/{job_id}/skill"),
|
||||
skill_body,
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
// ── SK-2: Has skill (true) ──────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn sk2_has_skill_true() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
seed_conversation(&services, 1).await;
|
||||
let created = create_job(&mut app, &token, &csrf, create_job_body("Skill Check")).await;
|
||||
let job_id = created["id"].as_str().unwrap();
|
||||
|
||||
let skill_body = json!({"content": "---\nname: x\n---\nContent"});
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
&format!("/api/cron/jobs/{job_id}/skill"),
|
||||
skill_body,
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
let req = get_with_token(&format!("/api/cron/jobs/{job_id}/skill"), &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["has_skill"], true);
|
||||
}
|
||||
|
||||
// ── SK-3: Has skill (false) ─────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn sk3_has_skill_false() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
seed_conversation(&services, 1).await;
|
||||
let created = create_job(&mut app, &token, &csrf, create_job_body("No Skill")).await;
|
||||
let job_id = created["id"].as_str().unwrap();
|
||||
|
||||
let req = get_with_token(&format!("/api/cron/jobs/{job_id}/skill"), &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["has_skill"], false);
|
||||
}
|
||||
|
||||
// ── SK-4: Save empty skill ──────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn sk4_save_empty_skill() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
seed_conversation(&services, 1).await;
|
||||
let created = create_job(&mut app, &token, &csrf, create_job_body("Empty Skill")).await;
|
||||
let job_id = created["id"].as_str().unwrap();
|
||||
|
||||
let skill_body = json!({"content": ""});
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
&format!("/api/cron/jobs/{job_id}/skill"),
|
||||
skill_body,
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// ── SK-5: Save placeholder skill ────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn sk5_save_placeholder_skill() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
seed_conversation(&services, 1).await;
|
||||
let created = create_job(&mut app, &token, &csrf, create_job_body("Placeholder Skill")).await;
|
||||
let job_id = created["id"].as_str().unwrap();
|
||||
|
||||
let skill_body = json!({"content": "TODO: fill in later"});
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
&format!("/api/cron/jobs/{job_id}/skill"),
|
||||
skill_body,
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// ── SK-6: Save skill for nonexistent job ─────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn sk6_save_skill_nonexistent() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let skill_body = json!({"content": "---\nname: x\n---\nOk"});
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/cron/jobs/cron_nonexistent/skill",
|
||||
skill_body,
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// ── SK-7: Delete existing skill ──────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn sk7_delete_skill() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
seed_conversation(&services, 1).await;
|
||||
let created = create_job(&mut app, &token, &csrf, create_job_body("Delete Skill Job")).await;
|
||||
let job_id = created["id"].as_str().unwrap();
|
||||
|
||||
let save_req = json_with_token(
|
||||
"POST",
|
||||
&format!("/api/cron/jobs/{job_id}/skill"),
|
||||
json!({"content": "---\nname: delete-me\n---\nContent"}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let save_resp = app.clone().oneshot(save_req).await.unwrap();
|
||||
assert_eq!(save_resp.status(), StatusCode::OK);
|
||||
|
||||
let delete_req = delete_with_token(&format!("/api/cron/jobs/{job_id}/skill"), &token, &csrf);
|
||||
let delete_resp = app.clone().oneshot(delete_req).await.unwrap();
|
||||
assert_eq!(delete_resp.status(), StatusCode::OK);
|
||||
|
||||
let has_req = get_with_token(&format!("/api/cron/jobs/{job_id}/skill"), &token);
|
||||
let has_resp = app.oneshot(has_req).await.unwrap();
|
||||
assert_eq!(has_resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(has_resp).await;
|
||||
assert_eq!(json["data"]["has_skill"], false);
|
||||
}
|
||||
|
||||
// ── SK-8: Delete skill for nonexistent job ───────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn sk8_delete_skill_nonexistent() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = delete_with_token("/api/cron/jobs/cron_nonexistent/skill", &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// ── SC-5: Invalid cron expression ────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn sc5_invalid_cron_expression() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let body = create_cron_job_body("Invalid Cron", "invalid cron");
|
||||
let req = json_with_token("POST", "/api/cron/jobs", body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// ── SC-6: Cron with timezone ─────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn sc6_cron_with_timezone() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let body = json!({
|
||||
"name": "Shanghai Job",
|
||||
"schedule": { "kind": "cron", "expr": "0 0 9 * * *", "tz": "Asia/Shanghai" },
|
||||
"message": "hello",
|
||||
"conversation_id": 1,
|
||||
"agent_type": "acp",
|
||||
"created_by": "user"
|
||||
});
|
||||
|
||||
seed_conversation(&services, 1).await;
|
||||
let data = create_job(&mut app, &token, &csrf, body).await;
|
||||
let now = nomifun_common::now_ms();
|
||||
assert!(data["state"]["next_run_at_ms"].as_i64().unwrap() > now);
|
||||
}
|
||||
|
||||
// ── SC-7: Every zero interval ────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn sc7_every_zero_interval() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let body = json!({
|
||||
"name": "Zero Interval",
|
||||
"schedule": { "kind": "every", "every_ms": 0 },
|
||||
"message": "x",
|
||||
"conversation_id": 1,
|
||||
"agent_type": "acp",
|
||||
"created_by": "user"
|
||||
});
|
||||
let req = json_with_token("POST", "/api/cron/jobs", body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// ── SC-8: Every negative interval ────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn sc8_every_negative_interval() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let body = json!({
|
||||
"name": "Negative Interval",
|
||||
"schedule": { "kind": "every", "every_ms": -1000 },
|
||||
"message": "x",
|
||||
"conversation_id": 1,
|
||||
"agent_type": "acp",
|
||||
"created_by": "user"
|
||||
});
|
||||
let req = json_with_token("POST", "/api/cron/jobs", body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
//! 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"
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,953 @@
|
||||
//! E2E tests for file operations (/api/fs/*).
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use common::{body_json, build_app, build_app_with_file_roots, json_with_token, setup_and_login};
|
||||
|
||||
// ===========================================================================
|
||||
// Auth guard
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn fs_endpoints_require_auth() {
|
||||
let (app, _services) = build_app().await;
|
||||
let endpoints = [
|
||||
"/api/fs/dir",
|
||||
"/api/fs/list",
|
||||
"/api/fs/metadata",
|
||||
"/api/fs/read",
|
||||
"/api/fs/write",
|
||||
"/api/fs/copy",
|
||||
"/api/fs/remove",
|
||||
"/api/fs/rename",
|
||||
"/api/fs/temp",
|
||||
"/api/fs/upload",
|
||||
"/api/fs/image-base64",
|
||||
"/api/fs/fetch-remote-image",
|
||||
"/api/fs/zip",
|
||||
"/api/fs/zip/cancel",
|
||||
"/api/fs/watch/start",
|
||||
"/api/fs/watch/stop",
|
||||
"/api/fs/watch/stop-all",
|
||||
"/api/fs/office-watch/start",
|
||||
"/api/fs/office-watch/stop",
|
||||
"/api/fs/snapshot/init",
|
||||
"/api/fs/snapshot/info",
|
||||
"/api/fs/snapshot/compare",
|
||||
"/api/fs/snapshot/baseline",
|
||||
"/api/fs/snapshot/stage",
|
||||
"/api/fs/snapshot/stage-all",
|
||||
"/api/fs/snapshot/unstage",
|
||||
"/api/fs/snapshot/unstage-all",
|
||||
"/api/fs/snapshot/discard",
|
||||
"/api/fs/snapshot/reset",
|
||||
"/api/fs/snapshot/branches",
|
||||
"/api/fs/snapshot/dispose",
|
||||
];
|
||||
|
||||
for uri in endpoints {
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri(uri)
|
||||
.header("content-type", "application/json")
|
||||
.body(axum::body::Body::from(r#"{}"#))
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::FORBIDDEN,
|
||||
"expected 403 for unauthenticated {uri}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Directory browsing
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_files_by_dir_returns_directory_contents() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
std::fs::write(root.join("hello.txt"), "world").unwrap();
|
||||
std::fs::create_dir(root.join("subdir")).unwrap();
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/fs/dir",
|
||||
json!({
|
||||
"dir": root.to_str().unwrap(),
|
||||
"root": root.to_str().unwrap()
|
||||
}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
|
||||
let data = json["data"].as_array().unwrap();
|
||||
assert!(data.len() >= 2, "should contain file + subdir");
|
||||
|
||||
let names: Vec<&str> = data.iter().filter_map(|e| e["name"].as_str()).collect();
|
||||
assert!(names.contains(&"hello.txt"));
|
||||
assert!(names.contains(&"subdir"));
|
||||
|
||||
// Check directory has isDir=true
|
||||
let subdir_entry = data.iter().find(|e| e["name"] == "subdir").unwrap();
|
||||
assert_eq!(subdir_entry["is_dir"], true);
|
||||
assert_eq!(subdir_entry["is_file"], false);
|
||||
|
||||
// Check file has isFile=true
|
||||
let file_entry = data.iter().find(|e| e["name"] == "hello.txt").unwrap();
|
||||
assert_eq!(file_entry["is_dir"], false);
|
||||
assert_eq!(file_entry["is_file"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_workspace_files_flat_list() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
std::fs::write(root.join("a.txt"), "a").unwrap();
|
||||
std::fs::create_dir(root.join("nested")).unwrap();
|
||||
std::fs::write(root.join("nested").join("b.txt"), "b").unwrap();
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/fs/list",
|
||||
json!({ "root": root.to_str().unwrap() }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
let data = json["data"].as_array().unwrap();
|
||||
assert!(data.len() >= 2, "should contain at least 2 files");
|
||||
|
||||
let names: Vec<&str> = data.iter().filter_map(|e| e["name"].as_str()).collect();
|
||||
assert!(names.contains(&"a.txt"));
|
||||
assert!(names.contains(&"b.txt"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_file_metadata_returns_info() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_path = dir.path().join("test.txt");
|
||||
std::fs::write(&file_path, "hello world").unwrap();
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/fs/metadata",
|
||||
json!({ "path": file_path.to_str().unwrap() }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["name"], "test.txt");
|
||||
assert_eq!(json["data"]["size"], 11); // "hello world" = 11 bytes
|
||||
assert!(json["data"]["last_modified"].as_i64().unwrap() > 0);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// File read/write
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_file_returns_content() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_path = dir.path().join("read_me.txt");
|
||||
std::fs::write(&file_path, "file content here").unwrap();
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/fs/read",
|
||||
json!({ "path": file_path.to_str().unwrap() }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"], "file content here");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_file_nonexistent_returns_null() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let fake_path = dir.path().join("nonexistent.txt");
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/fs/read",
|
||||
json!({ "path": fake_path.to_str().unwrap() }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["data"].is_null());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_file_with_workspace_field_accepts_non_home_path() {
|
||||
let sandbox = tempfile::tempdir().unwrap();
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let (mut app, services) = build_app_with_file_roots(vec![sandbox.path().to_path_buf()]).await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let file_path = workspace.path().join("preview.md");
|
||||
std::fs::write(&file_path, "# hello").unwrap();
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/fs/read",
|
||||
json!({
|
||||
"path": file_path.to_str().unwrap(),
|
||||
"workspace": workspace.path().to_str().unwrap()
|
||||
}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"], "# hello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_file_without_workspace_rejects_non_sandbox_path() {
|
||||
let sandbox = tempfile::tempdir().unwrap();
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let (mut app, services) = build_app_with_file_roots(vec![sandbox.path().to_path_buf()]).await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let file_path = workspace.path().join("preview.md");
|
||||
std::fs::write(&file_path, "# hello").unwrap();
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/fs/read",
|
||||
json!({ "path": file_path.to_str().unwrap() }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["code"], "PATH_OUTSIDE_SANDBOX");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_file_non_existent_within_sandbox_returns_null() {
|
||||
let sandbox = tempfile::tempdir().unwrap();
|
||||
let (mut app, services) = build_app_with_file_roots(vec![sandbox.path().to_path_buf()]).await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let file_path = sandbox.path().join("missing.md");
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/fs/read",
|
||||
json!({ "path": file_path.to_str().unwrap() }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["data"].is_null());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn image_base64_with_workspace_field_accepts_non_home_path() {
|
||||
let sandbox = tempfile::tempdir().unwrap();
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let (mut app, services) = build_app_with_file_roots(vec![sandbox.path().to_path_buf()]).await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let file_path = workspace.path().join("preview.png");
|
||||
std::fs::write(&file_path, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]).unwrap();
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/fs/image-base64",
|
||||
json!({
|
||||
"path": file_path.to_str().unwrap(),
|
||||
"workspace": workspace.path().to_str().unwrap()
|
||||
}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["data"].as_str().unwrap().starts_with("data:image/png;base64,"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_file_creates_and_returns_true() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_path = dir.path().join("new_file.txt");
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/fs/write",
|
||||
json!({
|
||||
"path": file_path.to_str().unwrap(),
|
||||
"data": "written via api",
|
||||
"workspace": dir.path().to_str().unwrap()
|
||||
}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"], true);
|
||||
|
||||
// Verify file actually written
|
||||
let content = std::fs::read_to_string(&file_path).unwrap();
|
||||
assert_eq!(content, "written via api");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_file_buffer_returns_base64() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_path = dir.path().join("binary.bin");
|
||||
std::fs::write(&file_path, [0x00, 0xFF, 0xAB]).unwrap();
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/fs/read-buffer",
|
||||
json!({ "path": file_path.to_str().unwrap() }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
let encoded = json["data"].as_str().unwrap();
|
||||
// Verify base64 roundtrip
|
||||
use base64::Engine;
|
||||
let decoded = base64::engine::general_purpose::STANDARD.decode(encoded).unwrap();
|
||||
assert_eq!(decoded, vec![0x00, 0xFF, 0xAB]);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// File management
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn copy_files_to_workspace() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let src_dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(src_dir.path().join("source.txt"), "content").unwrap();
|
||||
|
||||
let ws_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/fs/copy",
|
||||
json!({
|
||||
"file_paths": [src_dir.path().join("source.txt").to_str().unwrap()],
|
||||
"workspace": ws_dir.path().to_str().unwrap()
|
||||
}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
assert!(!json["data"]["copied_files"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_entry_deletes_file() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_path = dir.path().join("to_delete.txt");
|
||||
std::fs::write(&file_path, "bye").unwrap();
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/fs/remove",
|
||||
json!({
|
||||
"path": file_path.to_str().unwrap(),
|
||||
"workspace": dir.path().to_str().unwrap()
|
||||
}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert!(!file_path.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rename_entry_returns_new_path() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let old_path = dir.path().join("old.txt");
|
||||
std::fs::write(&old_path, "data").unwrap();
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/fs/rename",
|
||||
json!({
|
||||
"path": old_path.to_str().unwrap(),
|
||||
"new_name": "new.txt"
|
||||
}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
let new_path = json["data"]["new_path"].as_str().unwrap();
|
||||
assert!(new_path.contains("new.txt"));
|
||||
assert!(!old_path.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_temp_file_returns_path() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/fs/temp",
|
||||
json!({ "file_name": "temp_test.txt" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
let path = json["data"].as_str().unwrap();
|
||||
assert!(path.contains("temp_test.txt"));
|
||||
assert!(std::path::Path::new(path).exists());
|
||||
|
||||
// Cleanup
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Image processing
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_image_base64_returns_data_url() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let img_path = dir.path().join("pixel.png");
|
||||
// Minimal valid 1x1 PNG
|
||||
let png_bytes: &[u8] = &[
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00,
|
||||
0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00,
|
||||
0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xE2,
|
||||
0x21, 0xBC, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
|
||||
];
|
||||
std::fs::write(&img_path, png_bytes).unwrap();
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/fs/image-base64",
|
||||
json!({ "path": img_path.to_str().unwrap() }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
let data_url = json["data"].as_str().unwrap();
|
||||
assert!(data_url.starts_with("data:image/png;base64,"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_remote_image_non_whitelisted_returns_placeholder_svg() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/fs/fetch-remote-image",
|
||||
json!({ "url": "https://evil.example.com/image.png" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
let data_url = json["data"].as_str().unwrap();
|
||||
assert!(
|
||||
data_url.starts_with("data:image/svg+xml"),
|
||||
"expected placeholder SVG for non-whitelisted host"
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// ZIP operations
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_zip_with_text_content() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let zip_path = dir.path().join("test.zip");
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/fs/zip",
|
||||
json!({
|
||||
"path": zip_path.to_str().unwrap(),
|
||||
"files": [
|
||||
{ "name": "greeting.txt", "content": "hello zip" }
|
||||
]
|
||||
}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"], true);
|
||||
assert!(zip_path.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancel_zip_nonexistent_returns_false() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/fs/zip/cancel",
|
||||
json!({ "request_id": "nonexistent-id" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"], false);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// File watch
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn watch_stop_all_succeeds() {
|
||||
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("POST", "/api/fs/watch/stop-all", json!({}), &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Snapshot operations
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn snapshot_init_and_compare_on_plain_dir() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let workspace = dir.path();
|
||||
std::fs::write(workspace.join("file.txt"), "initial").unwrap();
|
||||
|
||||
// Init snapshot
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/fs/snapshot/init",
|
||||
json!({ "workspace": workspace.to_str().unwrap() }),
|
||||
&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["success"], true);
|
||||
assert_eq!(json["data"]["mode"], "snapshot");
|
||||
|
||||
// Get info
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/fs/snapshot/info",
|
||||
json!({ "workspace": workspace.to_str().unwrap() }),
|
||||
&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"]["mode"], "snapshot");
|
||||
|
||||
// Modify file and compare
|
||||
std::fs::write(workspace.join("file.txt"), "modified").unwrap();
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/fs/snapshot/compare",
|
||||
json!({ "workspace": workspace.to_str().unwrap() }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
let unstaged = json["data"]["unstaged"].as_array().unwrap();
|
||||
assert!(!unstaged.is_empty(), "should detect unstaged modification");
|
||||
assert_eq!(unstaged[0]["operation"], "modify");
|
||||
|
||||
// Get baseline content
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/fs/snapshot/baseline",
|
||||
json!({
|
||||
"workspace": workspace.to_str().unwrap(),
|
||||
"file_path": "file.txt"
|
||||
}),
|
||||
&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"], "initial");
|
||||
|
||||
// Dispose
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/fs/snapshot/dispose",
|
||||
json!({ "workspace": workspace.to_str().unwrap() }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snapshot_init_git_repo() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// Create a temporary git repo
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let workspace = dir.path();
|
||||
let repo = git2::Repository::init(workspace).unwrap();
|
||||
std::fs::write(workspace.join("readme.md"), "# hello").unwrap();
|
||||
|
||||
// Stage and commit
|
||||
let mut index = repo.index().unwrap();
|
||||
index.add_path(std::path::Path::new("readme.md")).unwrap();
|
||||
index.write().unwrap();
|
||||
let tree_id = index.write_tree().unwrap();
|
||||
let tree = repo.find_tree(tree_id).unwrap();
|
||||
let sig = git2::Signature::now("test", "test@test.com").unwrap();
|
||||
repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).unwrap();
|
||||
|
||||
// Init snapshot — should detect git-repo mode
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/fs/snapshot/init",
|
||||
json!({ "workspace": workspace.to_str().unwrap() }),
|
||||
&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"]["mode"], "git-repo");
|
||||
assert!(json["data"]["branch"].is_string());
|
||||
|
||||
// Branches
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/fs/snapshot/branches",
|
||||
json!({ "workspace": workspace.to_str().unwrap() }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
let branches = json["data"].as_array().unwrap();
|
||||
assert!(!branches.is_empty());
|
||||
|
||||
// Dispose
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/fs/snapshot/dispose",
|
||||
json!({ "workspace": workspace.to_str().unwrap() }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Path traversal rejection
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn path_traversal_rejected() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/fs/read",
|
||||
json!({ "path": "/tmp/../../../etc/passwd" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
// Should be rejected (400 bad request)
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// /api/fs/upload — multipart upload
|
||||
// ===========================================================================
|
||||
|
||||
struct UploadMultipart {
|
||||
boundary: String,
|
||||
parts: Vec<u8>,
|
||||
}
|
||||
|
||||
impl UploadMultipart {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
boundary: "----TestBoundaryFsUpload9XyZ".to_owned(),
|
||||
parts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_text(mut self, name: &str, value: &str) -> Self {
|
||||
self.parts
|
||||
.extend_from_slice(format!("--{}\r\n", self.boundary).as_bytes());
|
||||
self.parts
|
||||
.extend_from_slice(format!("Content-Disposition: form-data; name=\"{name}\"\r\n\r\n").as_bytes());
|
||||
self.parts.extend_from_slice(value.as_bytes());
|
||||
self.parts.extend_from_slice(b"\r\n");
|
||||
self
|
||||
}
|
||||
|
||||
fn add_file(mut self, name: &str, filename: &str, mime: &str, data: &[u8]) -> Self {
|
||||
self.parts
|
||||
.extend_from_slice(format!("--{}\r\n", self.boundary).as_bytes());
|
||||
self.parts.extend_from_slice(
|
||||
format!("Content-Disposition: form-data; name=\"{name}\"; filename=\"{filename}\"\r\n").as_bytes(),
|
||||
);
|
||||
self.parts
|
||||
.extend_from_slice(format!("Content-Type: {mime}\r\n\r\n").as_bytes());
|
||||
self.parts.extend_from_slice(data);
|
||||
self.parts.extend_from_slice(b"\r\n");
|
||||
self
|
||||
}
|
||||
|
||||
fn build(mut self) -> (String, Vec<u8>) {
|
||||
self.parts
|
||||
.extend_from_slice(format!("--{}--\r\n", self.boundary).as_bytes());
|
||||
let content_type = format!("multipart/form-data; boundary={}", self.boundary);
|
||||
(content_type, self.parts)
|
||||
}
|
||||
}
|
||||
|
||||
fn upload_request(content_type: &str, body: Vec<u8>, token: &str, csrf: &str) -> axum::http::Request<axum::body::Body> {
|
||||
let content_length = body.len();
|
||||
axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/fs/upload")
|
||||
.header("content-type", content_type)
|
||||
.header("content-length", content_length)
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("x-csrf-token", csrf)
|
||||
.header("cookie", format!("nomifun-csrf-token={csrf}"))
|
||||
.body(axum::body::Body::from(body))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_accepts_small_png_and_returns_readable_path() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// Minimal valid 1x1 PNG (67 bytes).
|
||||
let png_bytes: Vec<u8> = vec![
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00,
|
||||
0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00,
|
||||
0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xE2,
|
||||
0x21, 0xBC, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
|
||||
];
|
||||
|
||||
let conv_id = format!("conv-upload-{}", std::process::id());
|
||||
let (content_type, body) = UploadMultipart::new()
|
||||
.add_file("file", "paste.png", "image/png", &png_bytes)
|
||||
.add_text("conversation_id", &conv_id)
|
||||
.build();
|
||||
|
||||
let req = upload_request(&content_type, body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
let path = json["data"].as_str().expect("data should be a string path");
|
||||
let p = std::path::Path::new(path);
|
||||
assert!(p.is_absolute(), "returned path must be absolute: {path}");
|
||||
assert_eq!(p.file_name().unwrap().to_string_lossy(), "paste.png");
|
||||
// conversation_id routing produced a sub-directory of that name.
|
||||
assert_eq!(p.parent().unwrap().file_name().unwrap().to_string_lossy(), conv_id);
|
||||
// File contents must match what we uploaded.
|
||||
let on_disk = std::fs::read(p).expect("uploaded file should be readable");
|
||||
assert_eq!(on_disk, png_bytes);
|
||||
|
||||
// Cleanup.
|
||||
let _ = std::fs::remove_file(p);
|
||||
let _ = std::fs::remove_dir(p.parent().unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_uses_content_disposition_filename_when_file_name_missing() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let bytes = b"hello upload".to_vec();
|
||||
let unique = format!("dispo-{}.bin", std::process::id());
|
||||
let (content_type, body) = UploadMultipart::new()
|
||||
.add_file("file", &unique, "application/octet-stream", &bytes)
|
||||
.build();
|
||||
|
||||
let req = upload_request(&content_type, body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
let path = json["data"].as_str().unwrap();
|
||||
assert!(path.ends_with(&unique));
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_prefers_explicit_file_name_field_over_dispo() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let bytes = b"pref".to_vec();
|
||||
let explicit = format!("explicit-{}.bin", std::process::id());
|
||||
let (content_type, body) = UploadMultipart::new()
|
||||
.add_file("file", "dispo.bin", "application/octet-stream", &bytes)
|
||||
.add_text("file_name", &explicit)
|
||||
.build();
|
||||
|
||||
let req = upload_request(&content_type, body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
let path = json["data"].as_str().unwrap();
|
||||
assert!(path.ends_with(&explicit), "expected filename {explicit}, got {path}");
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_missing_file_field_returns_400() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let (content_type, body) = UploadMultipart::new().add_text("file_name", "ignored.png").build();
|
||||
|
||||
let req = upload_request(&content_type, body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_body_exceeding_30mb_returns_413() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// 31 MB payload comfortably exceeds UPLOAD_MAX_SIZE (30 MB).
|
||||
let big = vec![0u8; 31 * 1024 * 1024];
|
||||
let (content_type, body) = UploadMultipart::new()
|
||||
.add_file("file", "big.bin", "application/octet-stream", &big)
|
||||
.build();
|
||||
|
||||
let req = upload_request(&content_type, body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
//! E2E tests for the Desktop Gateway MCP server (`nomifun-gateway`).
|
||||
//!
|
||||
//! The gateway is not part of the axum app router — it is a separate
|
||||
//! localhost HTTP server started by `AppServices::from_config` and
|
||||
//! deps-wired by `create_router`. These tests exercise the full stack the
|
||||
//! way the stdio bridge does: authenticated `POST /tool` against the real
|
||||
//! port, with real services (memory db) behind it.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::build_app;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
struct Gateway {
|
||||
port: u16,
|
||||
token: String,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl Gateway {
|
||||
fn from_services(services: &nomifun_app::AppServices) -> Self {
|
||||
let cfg = services
|
||||
.gateway_mcp_config
|
||||
.as_ref()
|
||||
.expect("gateway MCP server must start in tests");
|
||||
Self {
|
||||
port: cfg.port,
|
||||
token: cfg.token.clone(),
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn call(&self, tool: &str, caller_conv: &str, user_id: &str, args: Value) -> Value {
|
||||
let resp = self
|
||||
.client
|
||||
.post(format!("http://127.0.0.1:{}/tool", self.port))
|
||||
.header("Authorization", format!("Bearer {}", self.token))
|
||||
.json(&json!({
|
||||
"tool": tool,
|
||||
"args": args,
|
||||
"conversation_id": caller_conv,
|
||||
"user_id": user_id,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("gateway reachable");
|
||||
resp.json().await.expect("json body")
|
||||
}
|
||||
}
|
||||
|
||||
/// Seed a user + one conversation directly (the gateway scopes everything by
|
||||
/// user id; HTTP signup/login is irrelevant to what we exercise here).
|
||||
///
|
||||
/// `conv_id` is an integer: after the single-track refactor `conversations.id`
|
||||
/// is an `INTEGER` autoincrement column, so the seed must insert a numeric id
|
||||
/// (string ids hit `datatype mismatch`).
|
||||
async fn seed_user_and_conversation(services: &nomifun_app::AppServices, user_id: &str, conv_id: i64) {
|
||||
seed_user_and_conversation_with_extra(services, user_id, conv_id, "{}").await;
|
||||
}
|
||||
|
||||
/// Same as [`seed_user_and_conversation`] but with a caller-provided `extra`
|
||||
/// JSON (e.g. a channel master-agent session carrying `companionId`).
|
||||
async fn seed_user_and_conversation_with_extra(
|
||||
services: &nomifun_app::AppServices,
|
||||
user_id: &str,
|
||||
conv_id: i64,
|
||||
extra: &str,
|
||||
) {
|
||||
sqlx::query("INSERT OR IGNORE INTO users (id, username, password_hash, created_at, updated_at) VALUES (?, ?, 'hash', 0, 0)")
|
||||
.bind(user_id)
|
||||
.bind(format!("user-{user_id}"))
|
||||
.execute(services.database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO conversations (id, user_id, name, type, extra, created_at, updated_at) \
|
||||
VALUES (?, ?, ?, 'nomi', ?, 0, 0)",
|
||||
)
|
||||
.bind(conv_id)
|
||||
.bind(user_id)
|
||||
.bind(format!("Conv {conv_id}"))
|
||||
.bind(extra)
|
||||
.execute(services.database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Seed an enabled provider so tools that resolve a default model (e.g.
|
||||
/// `nomi_cron_create` auto-filling a model-less nomi conversation) can
|
||||
/// complete their fallback chain.
|
||||
async fn seed_provider(services: &nomifun_app::AppServices, provider_id: &str, model: &str) {
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO providers \
|
||||
(id, platform, name, base_url, api_key_encrypted, models, enabled, created_at, updated_at) \
|
||||
VALUES (?, 'openai', ?, 'http://127.0.0.1:1', 'k', ?, 1, 0, 0)",
|
||||
)
|
||||
.bind(provider_id)
|
||||
.bind(format!("Provider {provider_id}"))
|
||||
.bind(format!("[\"{model}\"]"))
|
||||
.execute(services.database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn result_of(body: &Value) -> &Value {
|
||||
body.get("result")
|
||||
.unwrap_or_else(|| panic!("expected result, got {body}"))
|
||||
}
|
||||
|
||||
fn error_of(body: &Value) -> &str {
|
||||
body.get("error")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_else(|| panic!("expected error, got {body}"))
|
||||
}
|
||||
|
||||
// ── auth ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn gw_unauthenticated_tool_call_is_rejected() {
|
||||
let (_app, services) = build_app().await;
|
||||
let cfg = services.gateway_mcp_config.as_ref().unwrap();
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!("http://127.0.0.1:{}/tool", cfg.port))
|
||||
.json(&json!({"tool": "nomi_list_conversations", "args": {}}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status().as_u16(), 401);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gw_unknown_tool_returns_error() {
|
||||
let (_app, services) = build_app().await;
|
||||
let gw = Gateway::from_services(&services);
|
||||
let body = gw.call("nomi_explode_desktop", "", "u", json!({})).await;
|
||||
assert!(error_of(&body).contains("Unknown tool"));
|
||||
}
|
||||
|
||||
// ── conversations ────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn gw_list_conversations_returns_rows_with_runtime_state() {
|
||||
let (_app, services) = build_app().await;
|
||||
seed_user_and_conversation(&services, "user_gw", 1).await;
|
||||
seed_user_and_conversation(&services, "user_gw", 2).await;
|
||||
let gw = Gateway::from_services(&services);
|
||||
|
||||
let body = gw.call("nomi_list_conversations", "1", "user_gw", json!({})).await;
|
||||
let result = result_of(&body);
|
||||
assert_eq!(result["total"], json!(2));
|
||||
let convs = result["conversations"].as_array().unwrap();
|
||||
assert_eq!(convs.len(), 2);
|
||||
for conv in convs {
|
||||
assert_eq!(conv["runtime_state"], json!("idle"));
|
||||
}
|
||||
let self_marked: Vec<bool> = convs.iter().map(|c| c["is_self"].as_bool().unwrap()).collect();
|
||||
assert!(self_marked.contains(&true), "caller conversation flagged is_self");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gw_conversation_tools_require_user_identity() {
|
||||
let (_app, services) = build_app().await;
|
||||
let gw = Gateway::from_services(&services);
|
||||
let body = gw.call("nomi_list_conversations", "1", "", json!({})).await;
|
||||
assert!(error_of(&body).contains("user identity"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gw_list_conversations_excludes_companion_sessions() {
|
||||
let (_app, services) = build_app().await;
|
||||
// A companion (work-partner) single session…
|
||||
seed_user_and_conversation_with_extra(
|
||||
&services,
|
||||
"user_companion",
|
||||
1,
|
||||
r#"{"companionSession":true,"companionId":"companion_42"}"#,
|
||||
)
|
||||
.await;
|
||||
// …and a plain session with no binding.
|
||||
seed_user_and_conversation(&services, "user_companion", 2).await;
|
||||
let gw = Gateway::from_services(&services);
|
||||
|
||||
let body = gw.call("nomi_list_conversations", "", "user_companion", json!({})).await;
|
||||
let result = result_of(&body);
|
||||
let convs = result["conversations"].as_array().unwrap().clone();
|
||||
|
||||
// Product rule: a companion's own work-partner session is not part of the
|
||||
// session list — it is filtered out of BOTH the page and the total.
|
||||
assert_eq!(convs.len(), 1, "companion session excluded, got {convs:?}");
|
||||
assert_eq!(result["total"], json!(1));
|
||||
assert!(
|
||||
convs.iter().all(|c| c["id"] != json!(1)),
|
||||
"companion-bound conversation must be absent"
|
||||
);
|
||||
|
||||
// The surviving entry is the plain session, with no companion binding.
|
||||
// `conversations.id` is an INTEGER now → the `id` field is a JSON number.
|
||||
let plain = &convs[0];
|
||||
assert_eq!(plain["id"], json!(2));
|
||||
assert_eq!(plain["companion_id"], json!(null), "no binding → null, got {plain}");
|
||||
assert_eq!(plain["is_companion_companion"], json!(false));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gw_send_to_own_conversation_is_refused() {
|
||||
let (_app, services) = build_app().await;
|
||||
seed_user_and_conversation(&services, "user_gw", 1).await;
|
||||
let gw = Gateway::from_services(&services);
|
||||
let body = gw
|
||||
.call(
|
||||
"nomi_send_to_conversation",
|
||||
"1",
|
||||
"user_gw",
|
||||
json!({"conversation_id": 1, "content": "hi me"}),
|
||||
)
|
||||
.await;
|
||||
assert!(error_of(&body).contains("self_injection_forbidden"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gw_delete_own_conversation_is_refused_but_other_succeeds() {
|
||||
let (_app, services) = build_app().await;
|
||||
seed_user_and_conversation(&services, "user_gw", 1).await;
|
||||
seed_user_and_conversation(&services, "user_gw", 2).await;
|
||||
let gw = Gateway::from_services(&services);
|
||||
|
||||
let body = gw
|
||||
.call(
|
||||
"nomi_delete_conversation",
|
||||
"1",
|
||||
"user_gw",
|
||||
json!({"conversation_id": 1, "confirm": true}),
|
||||
)
|
||||
.await;
|
||||
assert!(error_of(&body).contains("self_deletion_forbidden"));
|
||||
|
||||
let body = gw
|
||||
.call(
|
||||
"nomi_delete_conversation",
|
||||
"1",
|
||||
"user_gw",
|
||||
json!({"conversation_id": 2, "confirm": true}),
|
||||
)
|
||||
.await;
|
||||
// `delete` echoes the stringified i64 id it parsed.
|
||||
assert_eq!(result_of(&body)["deleted"], json!("2"));
|
||||
|
||||
let body = gw.call("nomi_list_conversations", "1", "user_gw", json!({})).await;
|
||||
assert_eq!(result_of(&body)["total"], json!(1));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gw_conversation_status_reports_idle_and_messages() {
|
||||
let (_app, services) = build_app().await;
|
||||
seed_user_and_conversation(&services, "user_gw", 1).await;
|
||||
let gw = Gateway::from_services(&services);
|
||||
let body = gw
|
||||
.call(
|
||||
"nomi_conversation_status",
|
||||
"",
|
||||
"user_gw",
|
||||
json!({"conversation_id": 1}),
|
||||
)
|
||||
.await;
|
||||
let result = result_of(&body);
|
||||
assert_eq!(result["id"], json!(1));
|
||||
assert_eq!(result["runtime"]["state"], json!("idle"));
|
||||
assert!(result.get("recent_messages").is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gw_user_isolation_hides_other_users_conversations() {
|
||||
let (_app, services) = build_app().await;
|
||||
seed_user_and_conversation(&services, "user_a", 1).await;
|
||||
seed_user_and_conversation(&services, "user_b", 2).await;
|
||||
let gw = Gateway::from_services(&services);
|
||||
|
||||
// user_b cannot read or delete user_a's conversation.
|
||||
let body = gw
|
||||
.call(
|
||||
"nomi_conversation_status",
|
||||
"",
|
||||
"user_b",
|
||||
json!({"conversation_id": 1}),
|
||||
)
|
||||
.await;
|
||||
assert!(error_of(&body).contains("not found"), "got {body}");
|
||||
let body = gw
|
||||
.call(
|
||||
"nomi_delete_conversation",
|
||||
"",
|
||||
"user_b",
|
||||
json!({"conversation_id": 1, "confirm": true}),
|
||||
)
|
||||
.await;
|
||||
assert!(error_of(&body).contains("not found"), "got {body}");
|
||||
}
|
||||
|
||||
// ── cron ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn gw_cron_create_list_update_delete_roundtrip() {
|
||||
let (_app, services) = build_app().await;
|
||||
seed_user_and_conversation(&services, "user_gw", 1).await;
|
||||
// The conversation is seeded without a model: cron creation must resolve
|
||||
// one via the fallback chain (companion profile → first enabled provider), so a
|
||||
// provider has to exist — a model-less desktop is refused with guidance.
|
||||
seed_provider(&services, "prov_e2e", "test-model").await;
|
||||
let gw = Gateway::from_services(&services);
|
||||
|
||||
let body = gw
|
||||
.call(
|
||||
"nomi_cron_create",
|
||||
"1",
|
||||
"user_gw",
|
||||
json!({"name": "晨报", "cron": "0 9 * * *", "description": "每天 9 点", "message": "写晨报"}),
|
||||
)
|
||||
.await;
|
||||
// The create result became an object when the duplicate-guard and the
|
||||
// model-fallback note were introduced: {"message": ..., "model_note": ...}.
|
||||
let created = result_of(&body);
|
||||
let created_msg = created["message"].as_str().unwrap().to_owned();
|
||||
assert!(created_msg.contains("晨报"), "got {created_msg}");
|
||||
// The seeded conversation had no model — the fallback chain must have
|
||||
// auto-selected the only enabled provider and said so.
|
||||
let model_note = created["model_note"].as_str().unwrap_or_default();
|
||||
assert!(model_note.contains("prov_e2e") || model_note.contains("test-model"), "got model_note {model_note}");
|
||||
|
||||
let body = gw.call("nomi_cron_list", "1", "user_gw", json!({})).await;
|
||||
let jobs = result_of(&body).as_array().unwrap();
|
||||
assert_eq!(jobs.len(), 1);
|
||||
let job_id = jobs[0]["id"].as_str().unwrap().to_owned();
|
||||
assert_eq!(jobs[0]["name"], json!("晨报"));
|
||||
|
||||
let body = gw
|
||||
.call(
|
||||
"nomi_cron_update",
|
||||
"1",
|
||||
"user_gw",
|
||||
json!({"job_id": job_id, "name": "晚报", "cron": "0 21 * * *", "message": "写晚报"}),
|
||||
)
|
||||
.await;
|
||||
assert!(result_of(&body).as_str().unwrap().contains("晚报"));
|
||||
|
||||
let body = gw
|
||||
.call("nomi_cron_delete", "1", "user_gw", json!({"job_id": job_id, "confirm": true}))
|
||||
.await;
|
||||
assert!(result_of(&body).as_str().unwrap().contains("Deleted"));
|
||||
|
||||
let body = gw.call("nomi_cron_list", "1", "user_gw", json!({})).await;
|
||||
assert!(result_of(&body).as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
// ── global memory ────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn gw_memory_save_list_update_delete_roundtrip() {
|
||||
let (_app, services) = build_app().await;
|
||||
let gw = Gateway::from_services(&services);
|
||||
|
||||
let body = gw
|
||||
.call(
|
||||
"nomi_memory_save",
|
||||
"",
|
||||
"user_gw",
|
||||
json!({"content": "主人喜欢深色主题", "kind": "preference", "tags": ["ui"]}),
|
||||
)
|
||||
.await;
|
||||
let memory_id = result_of(&body)["id"].as_str().unwrap().to_owned();
|
||||
|
||||
let body = gw
|
||||
.call("nomi_memory_list", "", "user_gw", json!({"query": "深色主题"}))
|
||||
.await;
|
||||
let memories = result_of(&body).as_array().unwrap();
|
||||
assert_eq!(memories.len(), 1);
|
||||
assert_eq!(memories[0]["kind"], json!("preference"));
|
||||
|
||||
let body = gw
|
||||
.call("nomi_memory_update", "", "user_gw", json!({"id": memory_id, "pinned": true}))
|
||||
.await;
|
||||
assert!(result_of(&body).as_str().unwrap().contains("updated"));
|
||||
|
||||
let body = gw
|
||||
.call("nomi_memory_delete", "", "user_gw", json!({"id": memory_id, "confirm": true}))
|
||||
.await;
|
||||
assert!(result_of(&body).as_str().unwrap().contains("deleted"));
|
||||
|
||||
let body = gw
|
||||
.call("nomi_memory_list", "", "user_gw", json!({"query": "深色主题"}))
|
||||
.await;
|
||||
assert!(result_of(&body).as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gw_memory_update_with_no_fields_is_rejected() {
|
||||
let (_app, services) = build_app().await;
|
||||
let gw = Gateway::from_services(&services);
|
||||
let body = gw
|
||||
.call("nomi_memory_update", "", "user_gw", json!({"id": "mem_x"}))
|
||||
.await;
|
||||
assert!(error_of(&body).contains("nothing to update"));
|
||||
}
|
||||
|
||||
// ── requirements ─────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn gw_requirement_create_list_update_delete_roundtrip() {
|
||||
let (_app, services) = build_app().await;
|
||||
let gw = Gateway::from_services(&services);
|
||||
|
||||
let body = gw
|
||||
.call(
|
||||
"nomi_requirement_create",
|
||||
"",
|
||||
"user_gw",
|
||||
json!({"title": "修复登录页样式", "content": "按钮溢出", "tag": "前端"}),
|
||||
)
|
||||
.await;
|
||||
let req_id = result_of(&body)["id"].as_i64().unwrap();
|
||||
assert_eq!(result_of(&body)["created_by"], json!("agent"));
|
||||
|
||||
let body = gw
|
||||
.call("nomi_requirement_list", "", "user_gw", json!({"tag": "前端"}))
|
||||
.await;
|
||||
let items = result_of(&body)["items"].as_array().unwrap().clone();
|
||||
assert_eq!(items.len(), 1);
|
||||
|
||||
let body = gw
|
||||
.call(
|
||||
"nomi_requirement_update",
|
||||
"",
|
||||
"user_gw",
|
||||
json!({"id": req_id, "title": "修复登录页按钮溢出"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(result_of(&body)["title"], json!("修复登录页按钮溢出"));
|
||||
|
||||
let body = gw
|
||||
.call("nomi_requirement_delete", "", "user_gw", json!({"id": req_id, "confirm": true}))
|
||||
.await;
|
||||
assert!(result_of(&body).as_str().unwrap().contains("deleted"));
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use http_body_util::BodyExt;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use nomifun_app::{AppConfig, AppServices};
|
||||
|
||||
fn build_request(method: &str, uri: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.body(Body::empty())
|
||||
.expect("failed to build request")
|
||||
}
|
||||
|
||||
async fn response_json(body: Body) -> serde_json::Value {
|
||||
let bytes = body.collect().await.expect("failed to read body").to_bytes();
|
||||
serde_json::from_slice(&bytes).expect("failed to parse JSON")
|
||||
}
|
||||
|
||||
async fn build_app() -> axum::Router {
|
||||
let db = nomifun_db::init_database_memory().await.unwrap();
|
||||
let services = AppServices::from_config(db, &AppConfig::default()).await.unwrap();
|
||||
nomifun_app::create_router(&services).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_check_returns_ok() {
|
||||
let app = build_app().await;
|
||||
|
||||
let response = app
|
||||
.oneshot(build_request("GET", "/health"))
|
||||
.await
|
||||
.expect("request failed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let json = response_json(response.into_body()).await;
|
||||
assert_eq!(json["status"], "ok");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_check_post_blocked_by_csrf() {
|
||||
let app = build_app().await;
|
||||
|
||||
// POST without CSRF token is rejected by the global CSRF middleware
|
||||
let response = app
|
||||
.oneshot(build_request("POST", "/health"))
|
||||
.await
|
||||
.expect("request failed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_route_returns_not_found() {
|
||||
let app = build_app().await;
|
||||
|
||||
let response = app
|
||||
.oneshot(build_request("GET", "/nonexistent"))
|
||||
.await
|
||||
.expect("request failed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_check_has_security_headers() {
|
||||
let app = build_app().await;
|
||||
|
||||
let response = app
|
||||
.oneshot(build_request("GET", "/health"))
|
||||
.await
|
||||
.expect("request failed");
|
||||
|
||||
assert_eq!(response.headers().get("x-frame-options").unwrap(), "DENY");
|
||||
assert_eq!(response.headers().get("x-content-type-options").unwrap(), "nosniff");
|
||||
assert_eq!(response.headers().get("x-xss-protection").unwrap(), "1; mode=block");
|
||||
assert_eq!(
|
||||
response.headers().get("referrer-policy").unwrap(),
|
||||
"strict-origin-when-cross-origin"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,627 @@
|
||||
//! E2E tests for IDMM (Intelligent Decision-Making Mode) HTTP endpoints.
|
||||
//!
|
||||
//! Phase 2: the per-session config is two independently-toggled watches
|
||||
//! (`fault_watch` / `decision_watch`), each with a flattened [`WatchBase`]
|
||||
//! (enabled / tier / bypass_model / …). The `RulePlusModel` tier requires a
|
||||
//! resolvable bypass model; `RuleOnly` does not. The GET status surfaces
|
||||
//! `enabled` (= any watch on), `fault_enabled`, `decision_enabled`, and the
|
||||
//! persisted `config` blob for form rehydration.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use common::{
|
||||
body_json, build_app, delete_with_token, get_request, get_with_token, json_with_token, setup_and_login,
|
||||
};
|
||||
|
||||
async fn create_conversation(app: &mut axum::Router, token: &str, csrf: &str) -> String {
|
||||
let body = json!({ "type": "nomi", "name": "idmm-e2e", "extra": { "workspace": "/project" } });
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token("POST", "/api/conversations", body, token, csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
json["data"]["id"].as_i64().unwrap().to_string()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unauthenticated_get_is_rejected() {
|
||||
let (app, _services) = build_app().await;
|
||||
let resp = app
|
||||
.oneshot(get_request("/api/idmm/conversation/whatever"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
resp.status() == StatusCode::UNAUTHORIZED || resp.status() == StatusCode::FORBIDDEN,
|
||||
"expected 401/403, got {}",
|
||||
resp.status()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rule_only_config_roundtrip_on_conversation() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv = create_conversation(&mut app, &token, &csrf).await;
|
||||
|
||||
// Enable the decision watch at the RuleOnly tier (no bypass model required).
|
||||
let body = json!({
|
||||
"kind": "conversation",
|
||||
"target_id": conv,
|
||||
"decision_watch": { "enabled": true, "tier": "rule_only", "scan_interval_secs": 30, "max_retries": 3 }
|
||||
});
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token("POST", "/api/idmm", body, &token, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK, "RuleOnly enable should succeed");
|
||||
let j = body_json(resp).await;
|
||||
assert_eq!(j["data"]["enabled"], true);
|
||||
assert_eq!(j["data"]["decision_enabled"], true);
|
||||
assert_eq!(j["data"]["run_state"], "armed");
|
||||
|
||||
// Read it back.
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token(&format!("/api/idmm/conversation/{conv}"), &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let j = body_json(resp).await;
|
||||
assert_eq!(j["data"]["enabled"], true);
|
||||
assert_eq!(j["data"]["config"]["decision_watch"]["tier"], "rule_only");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn allow_unmarked_pick_flag_round_trips_on_conversation() {
|
||||
// The decision strategy's option-decision auto-pick flag must survive
|
||||
// serialize → DB → deserialize so the saved config rehydrates the form (and
|
||||
// the supervisor reads it). Set it explicitly and read it back.
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv = create_conversation(&mut app, &token, &csrf).await;
|
||||
|
||||
let body = json!({
|
||||
"kind": "conversation",
|
||||
"target_id": conv,
|
||||
"decision_watch": {
|
||||
"enabled": true,
|
||||
"tier": "rule_only",
|
||||
"strategy": { "categories": { "option_decision": { "allow_unmarked_pick": true } } }
|
||||
}
|
||||
});
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token("POST", "/api/idmm", body, &token, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK, "enabling RuleOnly auto-pick should succeed");
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token(&format!("/api/idmm/conversation/{conv}"), &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let j = body_json(resp).await;
|
||||
assert_eq!(
|
||||
j["data"]["config"]["decision_watch"]["strategy"]["categories"]["option_decision"]["allow_unmarked_pick"],
|
||||
true,
|
||||
"allow_unmarked_pick must persist and rehydrate; got {j:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_tier_without_freeform_policy_is_allowed() {
|
||||
// The strategy's freeform policy is OPTIONAL for the RulePlusModel tier (a
|
||||
// conservative built-in policy is used when empty). With a resolvable global
|
||||
// backup provider, enabling the model tier with no freeform must SUCCEED.
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv = create_conversation(&mut app, &token, &csrf).await;
|
||||
|
||||
let settings = json!({ "backup_provider_id": "prov-1", "default_steering_prompt": "" });
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token("PUT", "/api/idmm/settings", settings, &token, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body = json!({
|
||||
"kind": "conversation",
|
||||
"target_id": conv,
|
||||
"decision_watch": { "enabled": true, "tier": "rule_plus_model" }
|
||||
});
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token("POST", "/api/idmm", body, &token, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"empty freeform must be allowed once a backup model resolves"
|
||||
);
|
||||
let j = body_json(resp).await;
|
||||
assert_eq!(j["data"]["enabled"], true);
|
||||
assert_eq!(j["data"]["config"]["decision_watch"]["tier"], "rule_plus_model");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_tier_without_backup_provider_is_rejected() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv = create_conversation(&mut app, &token, &csrf).await;
|
||||
|
||||
// No global backup provider, no per-watch bypass model, and the e2e
|
||||
// conversation carries no model of its own → nothing resolves → must 400.
|
||||
let body = json!({
|
||||
"kind": "conversation",
|
||||
"target_id": conv,
|
||||
"decision_watch": { "enabled": true, "tier": "rule_plus_model" }
|
||||
});
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token("POST", "/api/idmm", body, &token, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
let j = body_json(resp).await;
|
||||
assert!(
|
||||
j["error"].as_str().unwrap_or("").contains("backup"),
|
||||
"error should mention the missing backup model, got {j:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fault_watch_model_tier_without_backup_is_rejected() {
|
||||
// The fault watch on the model tier carries the same backup requirement.
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv = create_conversation(&mut app, &token, &csrf).await;
|
||||
|
||||
let body = json!({
|
||||
"kind": "conversation",
|
||||
"target_id": conv,
|
||||
"fault_watch": { "enabled": true, "tier": "rule_plus_model" }
|
||||
});
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token("POST", "/api/idmm", body, &token, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_tier_with_global_backup_succeeds() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv = create_conversation(&mut app, &token, &csrf).await;
|
||||
|
||||
// Configure a global backup provider.
|
||||
let settings = json!({ "backup_provider_id": "prov-1", "backup_model": "m1", "default_steering_prompt": "" });
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token("PUT", "/api/idmm/settings", settings, &token, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body = json!({
|
||||
"kind": "conversation",
|
||||
"target_id": conv,
|
||||
"decision_watch": {
|
||||
"enabled": true,
|
||||
"tier": "rule_plus_model",
|
||||
"strategy": { "freeform_policy": "prefer the recommended option; never delete data" }
|
||||
}
|
||||
});
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token("POST", "/api/idmm", body, &token, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"model tier enable with a global backup should succeed"
|
||||
);
|
||||
let j = body_json(resp).await;
|
||||
assert_eq!(j["data"]["config"]["decision_watch"]["tier"], "rule_plus_model");
|
||||
assert_eq!(j["data"]["sidecar_provider_resolved"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn settings_roundtrip() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let settings = json!({
|
||||
"backup_provider_id": "prov-xyz",
|
||||
"backup_model": "model-xyz",
|
||||
"default_steering_prompt": "be conservative"
|
||||
});
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token("PUT", "/api/idmm/settings", settings, &token, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/idmm/settings", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let j = body_json(resp).await;
|
||||
assert_eq!(j["data"]["backup_provider_id"], "prov-xyz");
|
||||
assert_eq!(j["data"]["backup_model"], "model-xyz");
|
||||
assert_eq!(j["data"]["default_steering_prompt"], "be conservative");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_target_unknown_kind_is_rejected() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/idmm/bogus/some-id", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_target_not_found_is_rejected() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// A terminal that does not exist → ownership verification yields 404.
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/idmm/terminal/nonexistent-term", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_state_reports_off() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
// A conversation id that was never configured → default disabled state.
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/idmm/conversation/999999", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let j = body_json(resp).await;
|
||||
assert_eq!(j["data"]["enabled"], false);
|
||||
assert_eq!(j["data"]["run_state"], "off");
|
||||
}
|
||||
|
||||
// ── must always be able to disable + persisted config round-trips ──
|
||||
|
||||
#[tokio::test]
|
||||
async fn disable_model_watch_without_backup_succeeds() {
|
||||
// The user filled the model-tier form, then toggled off. The disable POST
|
||||
// sends the model tier but `enabled: false`. Pre-fix this returned 400 and
|
||||
// the user could not disable; post-fix the disable must always succeed (a
|
||||
// disabled watch carries no operational requirements).
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv = create_conversation(&mut app, &token, &csrf).await;
|
||||
|
||||
let body = json!({
|
||||
"kind": "conversation",
|
||||
"target_id": conv,
|
||||
"decision_watch": { "enabled": false, "tier": "rule_plus_model" }
|
||||
});
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token("POST", "/api/idmm", body, &token, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"disabling must always succeed regardless of model-tier prerequisites"
|
||||
);
|
||||
let j = body_json(resp).await;
|
||||
assert_eq!(j["data"]["enabled"], false);
|
||||
assert_eq!(j["data"]["run_state"], "off");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enabled_to_disabled_transition_succeeds_without_validation() {
|
||||
// The user enables the model tier with a global backup, later turns it off.
|
||||
// The disable POST must succeed even without the backup still resolving.
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv = create_conversation(&mut app, &token, &csrf).await;
|
||||
let settings = json!({ "backup_provider_id": "prov-1", "backup_model": "m1", "default_steering_prompt": "" });
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token("PUT", "/api/idmm/settings", settings, &token, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// Enable.
|
||||
let body = json!({
|
||||
"kind": "conversation",
|
||||
"target_id": conv,
|
||||
"decision_watch": { "enabled": true, "tier": "rule_plus_model" }
|
||||
});
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token("POST", "/api/idmm", body, &token, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// Disable → must still succeed.
|
||||
let body = json!({
|
||||
"kind": "conversation",
|
||||
"target_id": conv,
|
||||
"decision_watch": { "enabled": false, "tier": "rule_plus_model" }
|
||||
});
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token("POST", "/api/idmm", body, &token, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let j = body_json(resp).await;
|
||||
assert_eq!(j["data"]["enabled"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_status_round_trips_persisted_config() {
|
||||
// After saving a config, GET must return the persisted blob (both watches,
|
||||
// tiers, bypass model, strategy) so the frontend can rehydrate its form.
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv = create_conversation(&mut app, &token, &csrf).await;
|
||||
|
||||
let settings = json!({ "backup_provider_id": "prov-1", "backup_model": "m1", "default_steering_prompt": "" });
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token("PUT", "/api/idmm/settings", settings, &token, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body = json!({
|
||||
"kind": "conversation",
|
||||
"target_id": conv,
|
||||
"fault_watch": { "enabled": true, "tier": "rule_only", "scan_interval_secs": 45, "max_retries": 7 },
|
||||
"decision_watch": {
|
||||
"enabled": true,
|
||||
"tier": "rule_plus_model",
|
||||
"answer_open_questions": true,
|
||||
"bypass_model": { "provider_id": "prov-watch", "model": "m-watch" },
|
||||
"strategy": { "freeform_policy": "round-trip me", "tendency": "aggressive" }
|
||||
}
|
||||
});
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token("POST", "/api/idmm", body, &token, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token(&format!("/api/idmm/conversation/{conv}"), &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let j = body_json(resp).await;
|
||||
let cfg = j["data"]["config"].clone();
|
||||
assert!(
|
||||
cfg.is_object(),
|
||||
"GET must include the persisted IdmmConfig under .data.config; got {j:?}"
|
||||
);
|
||||
assert_eq!(cfg["fault_watch"]["enabled"], true);
|
||||
assert_eq!(cfg["fault_watch"]["tier"], "rule_only");
|
||||
assert_eq!(cfg["fault_watch"]["scan_interval_secs"], 45);
|
||||
assert_eq!(cfg["fault_watch"]["max_retries"], 7);
|
||||
assert_eq!(cfg["decision_watch"]["enabled"], true);
|
||||
assert_eq!(cfg["decision_watch"]["tier"], "rule_plus_model");
|
||||
assert_eq!(cfg["decision_watch"]["answer_open_questions"], true);
|
||||
assert_eq!(cfg["decision_watch"]["bypass_model"]["provider_id"], "prov-watch");
|
||||
assert_eq!(cfg["decision_watch"]["bypass_model"]["model"], "m-watch");
|
||||
assert_eq!(cfg["decision_watch"]["strategy"]["freeform_policy"], "round-trip me");
|
||||
assert_eq!(cfg["decision_watch"]["strategy"]["tendency"], "aggressive");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_phase1_blob_disables_gracefully() {
|
||||
// D3 back-compat at the HTTP layer: a Phase-1-shaped body (enabled/tier/
|
||||
// rule/sidecar/steering_prompt) must not error — serde ignores the unknown
|
||||
// fields and both watches deserialize to the default (disabled). The POST
|
||||
// succeeds and the target reports `enabled: false`.
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv = create_conversation(&mut app, &token, &csrf).await;
|
||||
|
||||
let body = json!({
|
||||
"kind": "conversation",
|
||||
"target_id": conv,
|
||||
"enabled": true,
|
||||
"tier": "rule_plus_sidecar",
|
||||
"steering_prompt": "prefer recommended",
|
||||
"rule": { "idle_threshold_secs": 30, "max_retries": 3, "auto_pick_unmarked": true },
|
||||
"sidecar": { "provider_id": "prov", "model": "m" }
|
||||
});
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token("POST", "/api/idmm", body, &token, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"a legacy Phase-1 blob must deserialize to default (disabled), not error"
|
||||
);
|
||||
let j = body_json(resp).await;
|
||||
assert_eq!(j["data"]["enabled"], false, "legacy blob → both watches default-disabled");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_status_omits_config_when_never_configured() {
|
||||
// Targets that were never saved must not carry a `config` field so the
|
||||
// frontend knows to seed from global defaults rather than a blank blob.
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/idmm/conversation/999999", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let j = body_json(resp).await;
|
||||
assert!(
|
||||
j["data"].get("config").is_none() || j["data"]["config"].is_null(),
|
||||
"unsaved targets must not carry a persisted config; got {j:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_conversation_cascades_idmm_records() {
|
||||
// IDMM records are disposable and carry no FK (polymorphic target_id), so the
|
||||
// app layer must clear them when the owning conversation is deleted. Insert a
|
||||
// record for a conversation target, delete the conversation via the HTTP route
|
||||
// (which fires the OnConversationDelete cascade hook), then assert the record
|
||||
// is gone.
|
||||
use nomifun_db::models::IdmmInterventionRow;
|
||||
use nomifun_db::{IIdmmInterventionRepository, SqliteIdmmInterventionRepository};
|
||||
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv = create_conversation(&mut app, &token, &csrf).await;
|
||||
|
||||
let records: SqliteIdmmInterventionRepository =
|
||||
SqliteIdmmInterventionRepository::new(services.database.pool().clone());
|
||||
|
||||
let row = IdmmInterventionRow {
|
||||
id: "idmmrec_cascade_test".into(),
|
||||
target_kind: "conversation".into(),
|
||||
target_id: conv.clone(),
|
||||
watch: "decision".into(),
|
||||
at: 1,
|
||||
signal: "decision".into(),
|
||||
tier_used: "rule".into(),
|
||||
category: Some("option".into()),
|
||||
action: "answer_choice".into(),
|
||||
detail: Some("option 2".into()),
|
||||
reason: Some("rule-tier auto-pick".into()),
|
||||
confidence: None,
|
||||
bypass_model: None,
|
||||
outcome: "applied".into(),
|
||||
};
|
||||
records.insert(&row).await.unwrap();
|
||||
|
||||
let before = records.list_for_target("conversation", &conv, 30).await.unwrap();
|
||||
assert_eq!(before.len(), 1, "record must exist before delete");
|
||||
|
||||
// Delete the conversation — the cascade hook runs inside the delete path.
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(delete_with_token(&format!("/api/conversations/{conv}"), &token, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK, "conversation delete should succeed");
|
||||
|
||||
let after = records.list_for_target("conversation", &conv, 30).await.unwrap();
|
||||
assert!(
|
||||
after.is_empty(),
|
||||
"IDMM records must be cascade-cleared when the conversation is deleted; got {after:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cross_session_activity_feed_round_trips() {
|
||||
// The cross-session activity feed reads every target's records most-recent-
|
||||
// first, and the bulk clear empties the whole table. Insert records for two
|
||||
// targets directly, then exercise GET + DELETE /api/idmm/activity.
|
||||
use nomifun_db::models::IdmmInterventionRow;
|
||||
use nomifun_db::{IIdmmInterventionRepository, SqliteIdmmInterventionRepository};
|
||||
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let records: SqliteIdmmInterventionRepository =
|
||||
SqliteIdmmInterventionRepository::new(services.database.pool().clone());
|
||||
|
||||
let make_row = |id: &str, target_kind: &str, target_id: &str, at: i64| IdmmInterventionRow {
|
||||
id: id.into(),
|
||||
target_kind: target_kind.into(),
|
||||
target_id: target_id.into(),
|
||||
watch: "decision".into(),
|
||||
at,
|
||||
signal: "decision".into(),
|
||||
tier_used: "rule".into(),
|
||||
category: Some("option".into()),
|
||||
action: "answer_choice".into(),
|
||||
detail: Some("option 2".into()),
|
||||
reason: Some("rule-tier auto-pick".into()),
|
||||
confidence: None,
|
||||
bypass_model: None,
|
||||
outcome: "applied".into(),
|
||||
};
|
||||
|
||||
// Two distinct targets, at interleaved → most-recent-first must mix them.
|
||||
records.insert(&make_row("idmmrec_act_a", "conversation", "c1", 10)).await.unwrap();
|
||||
records.insert(&make_row("idmmrec_act_b", "terminal", "1", 30)).await.unwrap();
|
||||
records.insert(&make_row("idmmrec_act_c", "conversation", "c2", 20)).await.unwrap();
|
||||
|
||||
// GET the feed → most-recent-first across ALL targets (30 -> 20 -> 10).
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/idmm/activity", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let j = body_json(resp).await;
|
||||
let items = j["data"].as_array().expect("activity feed is an array");
|
||||
let ids: Vec<&str> = items.iter().map(|r| r["id"].as_str().unwrap()).collect();
|
||||
assert_eq!(ids, vec!["idmmrec_act_b", "idmmrec_act_c", "idmmrec_act_a"]);
|
||||
// Spans both targets.
|
||||
assert_eq!(items[0]["target_kind"], "terminal");
|
||||
assert_eq!(items[1]["target_kind"], "conversation");
|
||||
|
||||
// DELETE the feed → clears every target's records.
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(delete_with_token("/api/idmm/activity", &token, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let j = body_json(resp).await;
|
||||
assert_eq!(j["data"], 3, "bulk clear must report the removed count");
|
||||
|
||||
// The feed is now empty.
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/idmm/activity", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let j = body_json(resp).await;
|
||||
assert!(
|
||||
j["data"].as_array().unwrap().is_empty(),
|
||||
"activity feed must be empty after bulk clear; got {j:?}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! End-to-end guard that IDMM ACTUALLY INTERVENES — not just that its config
|
||||
//! round-trips (which `idmm_e2e.rs` already covers).
|
||||
//!
|
||||
//! REGRESSION CONTEXT: 智能决策「无法主动触发决策 / 完全不可用」recurred several times
|
||||
//! (wrong-instance supervision hook — 6f7df38f; desktopGateway-as-routing —
|
||||
//! 74d85a5c + b2777ddd, fixed by ef487298; the on-arm pending-confirmation gap)
|
||||
//! WITHOUT a single failing test, because the only IDMM integration tests
|
||||
//! covered config persistence and never the arm → detect → intervene path.
|
||||
//!
|
||||
//! This test reproduces the user's exact gesture — a plain desktop nomi
|
||||
//! conversation whose agent is BLOCKED on a tool-permission "选择项" that was
|
||||
//! emitted BEFORE 智能决策 was enabled — and asserts the decision watch recovers
|
||||
//! that pending confirmation on arm and auto-confirms it. `observe()` only sees
|
||||
//! FUTURE events (it missed the pre-arm permission), so the on-arm
|
||||
//! `pending_signal` lane is the ONLY one that can recover it; before the fix it
|
||||
//! scanned persisted chat TEXT only and never saw a structured confirmation, so
|
||||
//! the agent stayed blocked forever and IDMM was silent. It fails if any link in
|
||||
//! arm / on_turn_start / ensure / pending_signal / policy / inject breaks.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use nomifun_ai_agent::types::{BuildTaskOptions, SendMessageData};
|
||||
use nomifun_ai_agent::{
|
||||
AgentInstance, AgentSendError, AgentStreamEvent, IAgentTask, IMockAgent, IWorkerTaskManager, WorkerTaskManagerImpl,
|
||||
};
|
||||
use nomifun_app::{AppConfig, AppServices, create_router};
|
||||
use nomifun_common::{
|
||||
AgentKillReason, AgentType, AppError, Confirmation, ConfirmationOption, ConversationStatus, TimestampMs, now_ms,
|
||||
};
|
||||
|
||||
use common::{body_json, json_with_token, setup_and_login};
|
||||
|
||||
/// A mock agent permanently BLOCKED on one safe (read-only) tool confirmation —
|
||||
/// the structured "选择项" the agent emitted before the watch armed. Records
|
||||
/// every `confirm()` it receives so the test can assert IDMM answered it.
|
||||
struct BlockedOnConfirmationAgent {
|
||||
conversation_id: String,
|
||||
confirmed: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl IAgentTask for BlockedOnConfirmationAgent {
|
||||
fn agent_type(&self) -> AgentType {
|
||||
AgentType::Nomi
|
||||
}
|
||||
fn conversation_id(&self) -> &str {
|
||||
&self.conversation_id
|
||||
}
|
||||
fn workspace(&self) -> &str {
|
||||
"/tmp/test"
|
||||
}
|
||||
fn status(&self) -> Option<ConversationStatus> {
|
||||
None
|
||||
}
|
||||
fn last_activity_at(&self) -> TimestampMs {
|
||||
now_ms()
|
||||
}
|
||||
fn subscribe(&self) -> tokio::sync::broadcast::Receiver<AgentStreamEvent> {
|
||||
// No live sender → the observe() live lane sees a closed stream, so the
|
||||
// ONLY way IDMM can act is the on-arm pending_signal lane (the point).
|
||||
let (tx, _) = tokio::sync::broadcast::channel(1);
|
||||
tx.subscribe()
|
||||
}
|
||||
async fn send_message(&self, _data: SendMessageData) -> Result<(), AgentSendError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn cancel(&self) -> Result<(), AppError> {
|
||||
Ok(())
|
||||
}
|
||||
fn kill(&self, _reason: Option<AgentKillReason>) -> Result<(), AppError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl IMockAgent for BlockedOnConfirmationAgent {
|
||||
fn get_confirmations(&self) -> Vec<Confirmation> {
|
||||
vec![Confirmation {
|
||||
id: "conf_1".into(),
|
||||
call_id: "call_42".into(),
|
||||
title: Some("读取 package.json".into()),
|
||||
action: None,
|
||||
description: "允许读取该文件?".into(),
|
||||
// read-only → command_type_is_safe → the rule tier auto-confirms the
|
||||
// safe "proceed once" option without needing a backup model.
|
||||
command_type: Some("read".into()),
|
||||
options: vec![
|
||||
ConfirmationOption {
|
||||
label: "允许一次".into(),
|
||||
value: json!("proceed_once"),
|
||||
params: None,
|
||||
},
|
||||
ConfirmationOption {
|
||||
label: "拒绝".into(),
|
||||
value: json!("cancel"),
|
||||
params: None,
|
||||
},
|
||||
],
|
||||
}]
|
||||
}
|
||||
fn confirm(
|
||||
&self,
|
||||
_msg_id: &str,
|
||||
call_id: &str,
|
||||
_data: serde_json::Value,
|
||||
_always_allow: bool,
|
||||
) -> Result<(), AppError> {
|
||||
self.confirmed.lock().unwrap().push(call_id.to_string());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an app whose agent factory returns a `BlockedOnConfirmationAgent`,
|
||||
/// sharing a recorder so the test can observe IDMM's auto-confirm.
|
||||
async fn build_app_blocked_on_confirmation() -> (axum::Router, AppServices, Arc<Mutex<Vec<String>>>) {
|
||||
let confirmed: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let confirmed_factory = confirmed.clone();
|
||||
let db = nomifun_db::init_database_memory().await.unwrap();
|
||||
let factory: Arc<
|
||||
dyn Fn(BuildTaskOptions) -> futures_util::future::BoxFuture<'static, Result<AgentInstance, AppError>>
|
||||
+ Send
|
||||
+ Sync,
|
||||
> = Arc::new(move |opts: BuildTaskOptions| {
|
||||
let confirmed = confirmed_factory.clone();
|
||||
Box::pin(async move {
|
||||
Ok(AgentInstance::Mock(Arc::new(BlockedOnConfirmationAgent {
|
||||
conversation_id: opts.conversation_id,
|
||||
confirmed,
|
||||
})))
|
||||
})
|
||||
});
|
||||
let wtm: Arc<dyn IWorkerTaskManager> = Arc::new(WorkerTaskManagerImpl::new(factory));
|
||||
let services = AppServices::from_config(db, &AppConfig::default())
|
||||
.await
|
||||
.unwrap()
|
||||
.with_worker_task_manager(wtm);
|
||||
let router = create_router(&services).await;
|
||||
(router, services, confirmed)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn idmm_recovers_and_confirms_on_arm_pending_tool_confirmation() {
|
||||
let (mut app, services, confirmed) = build_app_blocked_on_confirmation().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// A plain desktop nomi conversation (no channel/companion markers, no
|
||||
// channel_chat_id → is_plain_desktop / not-routed → IDMM may auto-answer).
|
||||
let conv = {
|
||||
let body = json!({ "type": "nomi", "name": "idmm-intervene", "extra": { "workspace": "/project" } });
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token("POST", "/api/conversations", body, &token, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp.status().is_success(), "create conversation failed: {}", resp.status());
|
||||
body_json(resp).await["data"]["id"].as_i64().unwrap().to_string()
|
||||
};
|
||||
|
||||
// Enable 决策值守 at the rule tier — a safe read-only confirmation is
|
||||
// auto-confirmed by the rule tier (no backup model required).
|
||||
let body = json!({
|
||||
"kind": "conversation",
|
||||
"target_id": conv,
|
||||
"decision_watch": { "enabled": true, "tier": "rule_only" }
|
||||
});
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token("POST", "/api/idmm", body, &token, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK, "enabling the decision watch should succeed");
|
||||
|
||||
// Send a message: this builds + registers the (already confirmation-blocked)
|
||||
// agent task and fires on_turn_start, which arms IDMM. The supervisor's
|
||||
// pending_signal must recover the live pending confirmation and auto-confirm
|
||||
// it — the agent emitted no future events (closed stream), so the on-arm lane
|
||||
// is the only one that can act.
|
||||
let body = json!({ "content": "帮我写一个贪吃蛇游戏,并在每个设计环节都回复我" });
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token(
|
||||
"POST",
|
||||
&format!("/api/conversations/{conv}/messages"),
|
||||
body,
|
||||
&token,
|
||||
&csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"send_message should accept the turn, got {}",
|
||||
resp.status()
|
||||
);
|
||||
|
||||
// Arming + pending_signal + inject happen on a detached task; poll for the
|
||||
// auto-confirm (no backoff on the on-arm pending decision, so it is prompt).
|
||||
let mut answered = false;
|
||||
for _ in 0..80 {
|
||||
if confirmed.lock().unwrap().iter().any(|c| c == "call_42") {
|
||||
answered = true;
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
}
|
||||
assert!(
|
||||
answered,
|
||||
"IDMM must recover the on-arm pending tool-confirmation and auto-confirm call_42 (decision watch was inert on pending confirmations); confirmed={:?}",
|
||||
confirmed.lock().unwrap()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use tower::ServiceExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_local_mode_skips_auth() {
|
||||
let db = nomifun_db::init_database_memory().await.unwrap();
|
||||
let config = nomifun_app::AppConfig {
|
||||
auth_policy: nomifun_app::AuthPolicy::NoAuth,
|
||||
..Default::default()
|
||||
};
|
||||
let services = nomifun_app::AppServices::from_config(db, &config).await.unwrap();
|
||||
|
||||
let router = nomifun_app::create_router(&services).await;
|
||||
|
||||
// Health check should work
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(Request::builder().uri("/health").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
// An authenticated endpoint should work WITHOUT a token in local mode
|
||||
let response = router
|
||||
.oneshot(Request::builder().uri("/api/settings").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(response.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
services.database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_non_local_mode_requires_auth() {
|
||||
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;
|
||||
|
||||
let response = router
|
||||
.oneshot(Request::builder().uri("/api/settings").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
services.database.close().await;
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
//! MCP server configuration CRUD E2E tests.
|
||||
//!
|
||||
//! Covers test-plan §1: create/read/update/delete/toggle/batch-import.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use common::{body_json, build_app, delete_with_token, get_with_token, json_with_token, setup_and_login};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn stdio_server_json(name: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"name": name,
|
||||
"description": "test stdio server",
|
||||
"transport": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@test/server"]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn http_server_json(name: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"name": name,
|
||||
"transport": {
|
||||
"type": "http",
|
||||
"url": "https://example.com/mcp"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn sse_server_json(name: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"name": name,
|
||||
"transport": {
|
||||
"type": "sse",
|
||||
"url": "https://example.com/sse",
|
||||
"headers": { "Authorization": "Bearer xxx" }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// C-1..C-3: Create different transport types
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_stdio_server() {
|
||||
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("POST", "/api/mcp/servers", stdio_server_json("test-mcp"), &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["success"].as_bool().unwrap());
|
||||
let data = &json["data"];
|
||||
assert!(data["id"].as_i64().unwrap() > 0);
|
||||
assert_eq!(data["name"], "test-mcp");
|
||||
assert_eq!(data["description"], "test stdio server");
|
||||
assert!(!data["enabled"].as_bool().unwrap());
|
||||
assert_eq!(data["transport"]["type"], "stdio");
|
||||
assert_eq!(data["transport"]["command"], "npx");
|
||||
assert_eq!(data["last_test_status"], "disconnected");
|
||||
assert!(!data["builtin"].as_bool().unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_http_server() {
|
||||
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("POST", "/api/mcp/servers", http_server_json("http-mcp"), &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["transport"]["type"], "http");
|
||||
assert_eq!(json["data"]["transport"]["url"], "https://example.com/mcp");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_sse_server_with_headers() {
|
||||
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("POST", "/api/mcp/servers", sse_server_json("sse-mcp"), &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["transport"]["type"], "sse");
|
||||
assert_eq!(json["data"]["transport"]["headers"]["Authorization"], "Bearer xxx");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// C-4: Upsert by name
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_same_name_upserts() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// Create initial
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/mcp/servers",
|
||||
stdio_server_json("upsert-test"),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let first = body_json(resp).await;
|
||||
let first_id = first["data"]["id"].as_i64().unwrap().to_string();
|
||||
|
||||
// Create again with same name — should update, not duplicate
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/mcp/servers",
|
||||
http_server_json("upsert-test"),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
let second = body_json(resp).await;
|
||||
assert_eq!(second["data"]["id"].as_i64().unwrap().to_string(), first_id);
|
||||
assert_eq!(second["data"]["transport"]["type"], "http");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// C-5..C-9: Validation errors
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_missing_name_returns_400() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/mcp/servers",
|
||||
json!({ "transport": { "type": "stdio", "command": "npx" } }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_missing_transport_returns_400() {
|
||||
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("POST", "/api/mcp/servers", json!({ "name": "test" }), &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_invalid_transport_type_returns_400() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/mcp/servers",
|
||||
json!({ "name": "test", "transport": { "type": "invalid", "command": "x" } }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// C-8: stdio transport missing command field
|
||||
#[tokio::test]
|
||||
async fn create_stdio_missing_command_returns_400() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/mcp/servers",
|
||||
json!({ "name": "test", "transport": { "type": "stdio" } }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// C-9: http/sse transport missing url field
|
||||
#[tokio::test]
|
||||
async fn create_http_missing_url_returns_400() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/mcp/servers",
|
||||
json!({ "name": "test", "transport": { "type": "http" } }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_sse_missing_url_returns_400() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/mcp/servers",
|
||||
json!({ "name": "test", "transport": { "type": "sse" } }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// R-1..R-4: Read operations
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_existing_server() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// Create
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/mcp/servers",
|
||||
stdio_server_json("read-test"),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
let id = json["data"]["id"].as_i64().unwrap().to_string();
|
||||
|
||||
// Get by ID
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token(&format!("/api/mcp/servers/{id}"), &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["name"], "read-test");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_nonexistent_server_returns_404() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/mcp/servers/nonexistent", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_servers_returns_all() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// Create two servers
|
||||
for name in ["list-a", "list-b"] {
|
||||
let req = json_with_token("POST", "/api/mcp/servers", stdio_server_json(name), &token, &csrf);
|
||||
app.clone().oneshot(req).await.unwrap();
|
||||
}
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/mcp/servers", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["data"].as_array().unwrap().len() >= 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_servers_empty() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/mcp/servers", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"], json!([]));
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// U-1..U-5: Update operations
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_server_name_is_rejected() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// Create
|
||||
let req = json_with_token("POST", "/api/mcp/servers", stdio_server_json("old-name"), &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
let id = json["data"]["id"].as_i64().unwrap().to_string();
|
||||
|
||||
// Renaming an MCP is not allowed because historical conversations reference its name.
|
||||
let req = json_with_token(
|
||||
"PUT",
|
||||
&format!("/api/mcp/servers/{id}"),
|
||||
json!({ "name": "new-name" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_server_transport() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// Create as stdio
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/mcp/servers",
|
||||
stdio_server_json("transport-test"),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
let id = json["data"]["id"].as_i64().unwrap().to_string();
|
||||
|
||||
// Update to http
|
||||
let req = json_with_token(
|
||||
"PUT",
|
||||
&format!("/api/mcp/servers/{id}"),
|
||||
json!({ "transport": { "type": "http", "url": "https://new.url" } }),
|
||||
&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"]["transport"]["type"], "http");
|
||||
assert_eq!(json["data"]["transport"]["url"], "https://new.url");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_server_description() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/mcp/servers",
|
||||
stdio_server_json("desc-test"),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
let id = json["data"]["id"].as_i64().unwrap().to_string();
|
||||
|
||||
let req = json_with_token(
|
||||
"PUT",
|
||||
&format!("/api/mcp/servers/{id}"),
|
||||
json!({ "description": "new description" }),
|
||||
&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"]["description"], "new description");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_nonexistent_server_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(
|
||||
"PUT",
|
||||
"/api/mcp/servers/nonexistent",
|
||||
json!({ "name": "x" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_name_to_existing_is_rejected() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// Create A and B
|
||||
let req = json_with_token("POST", "/api/mcp/servers", stdio_server_json("server-a"), &token, &csrf);
|
||||
app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
let req = json_with_token("POST", "/api/mcp/servers", stdio_server_json("server-b"), &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
let b_id = json["data"]["id"].as_i64().unwrap().to_string();
|
||||
|
||||
// Renaming is rejected before name conflict handling.
|
||||
let req = json_with_token(
|
||||
"PUT",
|
||||
&format!("/api/mcp/servers/{b_id}"),
|
||||
json!({ "name": "server-a" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// D-1..D-3: Delete operations
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_server() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// Create
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/mcp/servers",
|
||||
stdio_server_json("delete-me"),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
let id = json["data"]["id"].as_i64().unwrap().to_string();
|
||||
|
||||
// Delete
|
||||
let req = delete_with_token(&format!("/api/mcp/servers/{id}"), &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// Verify gone
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token(&format!("/api/mcp/servers/{id}"), &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_nonexistent_server_returns_404() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = delete_with_token("/api/mcp/servers/nonexistent", &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// T-1..T-3: Toggle
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn toggle_server_enables_then_disables() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// Create (starts disabled)
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/mcp/servers",
|
||||
stdio_server_json("toggle-test"),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
let id = json["data"]["id"].as_i64().unwrap().to_string();
|
||||
assert!(!json["data"]["enabled"].as_bool().unwrap());
|
||||
|
||||
// Toggle → enabled
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
&format!("/api/mcp/servers/{id}/toggle"),
|
||||
json!({}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["data"]["enabled"].as_bool().unwrap());
|
||||
|
||||
// Toggle → disabled
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
&format!("/api/mcp/servers/{id}/toggle"),
|
||||
json!({}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert!(!json["data"]["enabled"].as_bool().unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn toggle_nonexistent_server_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("POST", "/api/mcp/servers/nonexistent/toggle", json!({}), &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// B-1..B-3: Batch import
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn batch_import_creates_multiple() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/mcp/servers/import",
|
||||
json!({
|
||||
"servers": [
|
||||
{ "name": "import-a", "transport": { "type": "stdio", "command": "npx" } },
|
||||
{ "name": "import-b", "transport": { "type": "http", "url": "https://example.com" } },
|
||||
{ "name": "import-c", "transport": { "type": "sse", "url": "https://example.com/sse" } }
|
||||
]
|
||||
}),
|
||||
&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"].as_array().unwrap().len(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn batch_import_upserts_existing() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// Create one first
|
||||
let req = json_with_token("POST", "/api/mcp/servers", stdio_server_json("existing"), &token, &csrf);
|
||||
app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
// Batch import with one existing and one new
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/mcp/servers/import",
|
||||
json!({
|
||||
"servers": [
|
||||
{ "name": "existing", "transport": { "type": "http", "url": "https://updated.com" } },
|
||||
{ "name": "brand-new", "transport": { "type": "stdio", "command": "node" } }
|
||||
]
|
||||
}),
|
||||
&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"].as_array().unwrap().len(), 2);
|
||||
|
||||
// Verify total count is 2 (not 3)
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/mcp/servers", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"].as_array().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn batch_import_empty_list() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/mcp/servers/import",
|
||||
json!({ "servers": [] }),
|
||||
&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"], json!([]));
|
||||
}
|
||||
|
||||
// B-4: Batch import with invalid config rejects the whole request
|
||||
#[tokio::test]
|
||||
async fn batch_import_with_invalid_config_returns_400() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/mcp/servers/import",
|
||||
json!({
|
||||
"servers": [
|
||||
{ "name": "valid", "transport": { "type": "stdio", "command": "npx" } },
|
||||
{ "name": "invalid", "transport": { "type": "unknown" } }
|
||||
]
|
||||
}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// AU-1: Auth required (CSRF middleware returns 403 before auth checks)
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn unauthenticated_access_is_rejected() {
|
||||
let (app, _services) = build_app().await;
|
||||
|
||||
// GET without token — CSRF middleware rejects before auth can run
|
||||
let req = axum::http::Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/mcp/servers")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! MCP E2E tests beyond CRUD: connection test, agent config discovery, OAuth, auth.
|
||||
//!
|
||||
//! Covers test-plan sections 2 (connection test error paths), 3 (agent config discovery),
|
||||
//! 4 (OAuth status), and 6 (authentication).
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use common::{body_json, build_app, get_with_token, json_with_token, setup_and_login};
|
||||
|
||||
// ===========================================================================
|
||||
// CT-3: Connection test — command not found (ENOENT)
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn connection_test_enoent_command() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/mcp/test-connection",
|
||||
json!({
|
||||
"name": "enoent-test",
|
||||
"transport": {
|
||||
"type": "stdio",
|
||||
"command": "nonexistent-mcp-command-xyz-12345"
|
||||
}
|
||||
}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert!(!json["success"].as_bool().unwrap());
|
||||
assert_eq!(json["code"], "MCP_COMMAND_NOT_FOUND");
|
||||
assert_eq!(json["details"]["command"], "nonexistent-mcp-command-xyz-12345");
|
||||
assert!(!json["error"].as_str().unwrap().is_empty());
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// CT-4: Connection test — unreachable URL
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn connection_test_unreachable_url() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/mcp/test-connection",
|
||||
json!({
|
||||
"name": "unreachable-test",
|
||||
"transport": {
|
||||
"type": "http",
|
||||
"url": "http://127.0.0.1:19999/mcp"
|
||||
}
|
||||
}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert!(!json["success"].as_bool().unwrap());
|
||||
assert_eq!(json["code"], "MCP_CONNECTION_FAILED");
|
||||
assert_eq!(json["details"]["transport"], "http");
|
||||
assert!(!json["error"].as_str().unwrap().is_empty());
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// AS-1: Get agent configs (may return empty in test env)
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_agent_configs() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/mcp/agent-configs", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["success"].as_bool().unwrap());
|
||||
// In test env, data is an array (may be empty or contain nomifun adapter)
|
||||
assert!(json["data"].is_array());
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// OA-1: OAuth check status — unauthenticated server
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn oauth_check_status_unauthenticated_server() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/mcp/oauth/check-status",
|
||||
json!({ "server_url": "https://unknown-server.example.com" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["success"].as_bool().unwrap());
|
||||
assert!(!json["data"]["authenticated"].as_bool().unwrap());
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// OA-3: Get all authenticated servers (empty at start)
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn oauth_authenticated_servers_empty() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/mcp/oauth/authenticated", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["success"].as_bool().unwrap());
|
||||
assert_eq!(json["data"], json!([]));
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// OA-7: Logout from never-authenticated server (idempotent)
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn oauth_logout_idempotent() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/mcp/oauth/logout",
|
||||
json!({ "server_url": "https://never-authed.example.com" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["success"].as_bool().unwrap());
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// AU-1: Unauthenticated access to various MCP endpoints
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn unauthenticated_get_servers_rejected() {
|
||||
let (app, _services) = build_app().await;
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/mcp/servers")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
// CSRF middleware rejects before auth can run
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unauthenticated_post_server_rejected() {
|
||||
let (app, _services) = build_app().await;
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/mcp/servers")
|
||||
.header("content-type", "application/json")
|
||||
.body(axum::body::Body::from(
|
||||
serde_json::to_vec(&json!({
|
||||
"name": "test",
|
||||
"transport": { "type": "stdio", "command": "npx" }
|
||||
}))
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// AU-3: Valid token accesses MCP routes successfully
|
||||
#[tokio::test]
|
||||
async fn authenticated_access_succeeds() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/mcp/servers", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
// AU-2: Invalid Bearer token is rejected by auth middleware (403 per API spec)
|
||||
#[tokio::test]
|
||||
async fn invalid_token_rejected() {
|
||||
let (app, _services) = build_app().await;
|
||||
|
||||
// GET bypasses CSRF → auth middleware sees invalid Bearer → 403
|
||||
let req = axum::http::Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/mcp/servers")
|
||||
.header("authorization", "Bearer invalid-jwt-token-abc123")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
@@ -0,0 +1,934 @@
|
||||
//! E2E tests for message listing, search, pagination, and auth protection.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use common::{body_json, build_app, build_app_with_mock_agents, get_request, get_with_token, setup_and_login};
|
||||
use nomifun_db::{ConversationRowUpdate, IConversationRepository};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
fn create_conv_body(name: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "acp",
|
||||
"name": name,
|
||||
"extra": { "workspace": "/project", "backend": "gemini" }
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_conversation(app: &mut axum::Router, token: &str, csrf: &str, name: &str) -> i64 {
|
||||
let req = common::json_with_token("POST", "/api/conversations", create_conv_body(name), token, csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let json = common::body_json(resp).await;
|
||||
json["data"]["id"].as_i64().unwrap()
|
||||
}
|
||||
|
||||
async fn insert_message(
|
||||
services: &nomifun_app::AppServices,
|
||||
conv_id: i64,
|
||||
msg_id: &str,
|
||||
content: &str,
|
||||
created_at: i64,
|
||||
) {
|
||||
let repo = nomifun_db::SqliteConversationRepository::new(services.database.pool().clone());
|
||||
let msg = nomifun_db::models::MessageRow {
|
||||
id: msg_id.into(),
|
||||
conversation_id: conv_id,
|
||||
msg_id: None,
|
||||
r#type: "text".into(),
|
||||
content: serde_json::json!({"content": content}).to_string(),
|
||||
position: Some("right".into()),
|
||||
status: Some("finish".into()),
|
||||
hidden: false,
|
||||
created_at,
|
||||
};
|
||||
nomifun_db::IConversationRepository::insert_message(&repo, &msg)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn update_conversation_workspace(services: &nomifun_app::AppServices, conv_id: i64, workspace: &str) {
|
||||
let repo = nomifun_db::SqliteConversationRepository::new(services.database.pool().clone());
|
||||
IConversationRepository::update(
|
||||
&repo,
|
||||
conv_id,
|
||||
&ConversationRowUpdate {
|
||||
extra: Some(json!({ "workspace": workspace, "backend": "gemini" }).to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn insert_acp_tool_message(
|
||||
services: &nomifun_app::AppServices,
|
||||
conv_id: i64,
|
||||
msg_id: &str,
|
||||
output: &str,
|
||||
created_at: i64,
|
||||
) {
|
||||
let repo = nomifun_db::SqliteConversationRepository::new(services.database.pool().clone());
|
||||
let msg = nomifun_db::models::MessageRow {
|
||||
id: msg_id.into(),
|
||||
conversation_id: conv_id,
|
||||
msg_id: Some(msg_id.into()),
|
||||
r#type: "acp_tool_call".into(),
|
||||
content: serde_json::json!({
|
||||
"session_id": "session-1",
|
||||
"update": {
|
||||
"session_update": "tool_call",
|
||||
"tool_call_id": msg_id,
|
||||
"status": "completed",
|
||||
"title": "rg",
|
||||
"kind": "search",
|
||||
"raw_input": { "pattern": "needle", "path": "." },
|
||||
"content": [{
|
||||
"type": "content",
|
||||
"content": { "type": "text", "text": output }
|
||||
}]
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
position: Some("left".into()),
|
||||
status: Some("finish".into()),
|
||||
hidden: false,
|
||||
created_at,
|
||||
};
|
||||
nomifun_db::IConversationRepository::insert_message(&repo, &msg)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn upsert_artifact(services: &nomifun_app::AppServices, artifact: nomifun_db::ConversationArtifactRow) -> i64 {
|
||||
let repo = nomifun_db::SqliteConversationRepository::new(services.database.pool().clone());
|
||||
nomifun_db::IConversationRepository::upsert_artifact(&repo, &artifact)
|
||||
.await
|
||||
.unwrap()
|
||||
.id
|
||||
}
|
||||
|
||||
/// Seed a minimal `cron_jobs` parent row so artifacts referencing it satisfy
|
||||
/// the `conversation_artifacts.cron_job_id -> cron_jobs(id)` foreign key.
|
||||
async fn seed_cron_job(services: &nomifun_app::AppServices, id: &str) {
|
||||
sqlx::query(
|
||||
"INSERT INTO cron_jobs \
|
||||
(id, name, schedule_kind, schedule_value, payload_message, agent_type, created_by, created_at, updated_at) \
|
||||
VALUES (?, 'Job', 'every', '60000', 'msg', 'acp', 'user', 0, 0)",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(services.database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// ── T8: Message list ──────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_1_messages_empty() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Empty Conv").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_token(
|
||||
&format!("/api/conversations/{conv_id}/messages"),
|
||||
&token,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["items"].as_array().unwrap().len(), 0);
|
||||
assert_eq!(json["data"]["total"], 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_2_messages_pagination() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Paginated Conv").await;
|
||||
|
||||
// Insert 10 messages
|
||||
for i in 0..10 {
|
||||
insert_message(
|
||||
&services,
|
||||
conv_id,
|
||||
&format!("msg-{i}"),
|
||||
&format!("Message {i}"),
|
||||
1000 + i * 100,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Page 1, page_size 3
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token(
|
||||
&format!("/api/conversations/{conv_id}/messages?page=1&page_size=3"),
|
||||
&token,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["items"].as_array().unwrap().len(), 3);
|
||||
assert_eq!(json["data"]["total"], 10);
|
||||
assert_eq!(json["data"]["has_more"], true);
|
||||
|
||||
// Last page
|
||||
let resp = app
|
||||
.oneshot(get_with_token(
|
||||
&format!("/api/conversations/{conv_id}/messages?page=4&page_size=3"),
|
||||
&token,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["items"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(json["data"]["has_more"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_2b_messages_compact_mode_truncates_large_tool_payload() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Compact Tool Conv").await;
|
||||
let large_output = "match line\n".repeat(10_000);
|
||||
|
||||
insert_acp_tool_message(&services, conv_id, "tool-big", &large_output, 1000).await;
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token(
|
||||
&format!("/api/conversations/{conv_id}/messages?content_mode=compact"),
|
||||
&token,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
let content = &json["data"]["items"][0]["content"];
|
||||
let preview = content["update"]["content"][0]["content"]["text"].as_str().unwrap();
|
||||
|
||||
assert_eq!(content["_compact"]["truncated"], true);
|
||||
assert!(preview.len() < large_output.len());
|
||||
assert!(!preview.contains(&large_output));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_2c_get_message_returns_full_tool_payload() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Tool Detail Conv").await;
|
||||
let large_output = "wide rg output\n".repeat(10_000);
|
||||
|
||||
insert_acp_tool_message(&services, conv_id, "tool-detail", &large_output, 1000).await;
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token(
|
||||
&format!("/api/conversations/{conv_id}/messages/tool-detail"),
|
||||
&token,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
|
||||
assert_eq!(
|
||||
json["data"]["content"]["update"]["content"][0]["content"]["text"]
|
||||
.as_str()
|
||||
.unwrap(),
|
||||
large_output
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_2d_get_message_requires_auth() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Tool Detail Auth Conv").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_request(&format!(
|
||||
"/api/conversations/{conv_id}/messages/tool-detail"
|
||||
)))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_2e_get_message_not_found_returns_specific_error() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Tool Detail Missing Conv").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_token(
|
||||
&format!("/api/conversations/{conv_id}/messages/missing-message"),
|
||||
&token,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
let json = body_json(resp).await;
|
||||
|
||||
assert_eq!(json["code"], "NOT_FOUND");
|
||||
assert!(
|
||||
json["error"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("Message missing-message not found")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_2f_get_message_does_not_leak_cross_user_conversation() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (owner_token, owner_csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let owner_conv_id = create_conversation(&mut app, &owner_token, &owner_csrf, "Owner Tool Conv").await;
|
||||
insert_acp_tool_message(&services, owner_conv_id, "owner-tool", "private output", 1000).await;
|
||||
|
||||
let (other_token, _other_csrf) = setup_and_login(&mut app, &services, "other-user", "StrongP@ss2").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_token(
|
||||
&format!("/api/conversations/{owner_conv_id}/messages/owner-tool"),
|
||||
&other_token,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
let json = body_json(resp).await;
|
||||
|
||||
assert_eq!(json["code"], "NOT_FOUND");
|
||||
assert!(
|
||||
json["error"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains(&format!("Conversation {owner_conv_id} not found"))
|
||||
);
|
||||
assert!(!json["error"].as_str().unwrap().contains("owner-tool"));
|
||||
assert!(!json["error"].as_str().unwrap().contains("private output"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_3_messages_order_asc_default() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Order Test").await;
|
||||
|
||||
insert_message(&services, conv_id, "msg-old", "Old", 1000).await;
|
||||
insert_message(&services, conv_id, "msg-mid", "Mid", 2000).await;
|
||||
insert_message(&services, conv_id, "msg-new", "New", 3000).await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_token(
|
||||
&format!("/api/conversations/{conv_id}/messages"),
|
||||
&token,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
let items = json["data"]["items"].as_array().unwrap();
|
||||
// ASC order (default): oldest first
|
||||
assert!(items[0]["created_at"].as_i64().unwrap() < items[1]["created_at"].as_i64().unwrap());
|
||||
assert!(items[1]["created_at"].as_i64().unwrap() < items[2]["created_at"].as_i64().unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_4_messages_order_asc() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "ASC Test").await;
|
||||
|
||||
insert_message(&services, conv_id, "msg-old", "Old", 1000).await;
|
||||
insert_message(&services, conv_id, "msg-mid", "Mid", 2000).await;
|
||||
insert_message(&services, conv_id, "msg-new", "New", 3000).await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_token(
|
||||
&format!("/api/conversations/{conv_id}/messages?order=ASC"),
|
||||
&token,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
let items = json["data"]["items"].as_array().unwrap();
|
||||
// ASC order: oldest first
|
||||
assert!(items[0]["created_at"].as_i64().unwrap() < items[1]["created_at"].as_i64().unwrap());
|
||||
assert!(items[1]["created_at"].as_i64().unwrap() < items[2]["created_at"].as_i64().unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_5_messages_conversation_not_found() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_token("/api/conversations/non-existent/messages", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_6_messages_requires_auth() {
|
||||
let (app, _services) = build_app().await;
|
||||
let resp = app
|
||||
.oneshot(get_request("/api/conversations/some-id/messages"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_7_messages_exclude_legacy_cron_rows() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Legacy Filter").await;
|
||||
|
||||
insert_message(&services, conv_id, "msg-text", "Visible", 1000).await;
|
||||
|
||||
let repo = nomifun_db::SqliteConversationRepository::new(services.database.pool().clone());
|
||||
for (id, ty, content) in [
|
||||
(
|
||||
"legacy-cron",
|
||||
"cron_trigger",
|
||||
json!({
|
||||
"cron_job_id": "cron_1",
|
||||
"cron_job_name": "Daily",
|
||||
"triggered_at": 2000
|
||||
}),
|
||||
),
|
||||
(
|
||||
"legacy-skill",
|
||||
"skill_suggest",
|
||||
json!({
|
||||
"cron_job_id": "cron_1",
|
||||
"name": "daily-report",
|
||||
"description": "Daily report",
|
||||
"skillContent": "---\nname: daily-report\n---\nUse it."
|
||||
}),
|
||||
),
|
||||
] {
|
||||
let msg = nomifun_db::models::MessageRow {
|
||||
id: id.into(),
|
||||
conversation_id: conv_id.clone(),
|
||||
msg_id: None,
|
||||
r#type: ty.into(),
|
||||
content: content.to_string(),
|
||||
position: Some("center".into()),
|
||||
status: Some("finish".into()),
|
||||
hidden: false,
|
||||
created_at: 2000,
|
||||
};
|
||||
nomifun_db::IConversationRepository::insert_message(&repo, &msg)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_token(
|
||||
&format!("/api/conversations/{conv_id}/messages"),
|
||||
&token,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
let items = json["data"]["items"].as_array().unwrap();
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(json["data"]["total"], 1);
|
||||
assert_eq!(items[0]["type"], "text");
|
||||
assert_eq!(items[0]["content"]["content"], "Visible");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_8_artifacts_list_and_patch_status() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Artifacts").await;
|
||||
seed_cron_job(&services, "cron_1").await;
|
||||
|
||||
let artifact_id = upsert_artifact(
|
||||
&services,
|
||||
nomifun_db::ConversationArtifactRow {
|
||||
id: 0,
|
||||
conversation_id: conv_id.clone(),
|
||||
cron_job_id: Some("cron_1".into()),
|
||||
kind: "skill_suggest".into(),
|
||||
status: "active".into(),
|
||||
payload: json!({
|
||||
"cron_job_id": "cron_1",
|
||||
"name": "daily-report",
|
||||
"description": "Daily report",
|
||||
"skillContent": "---\nname: daily-report\n---\nUse it."
|
||||
})
|
||||
.to_string(),
|
||||
created_at: 1000,
|
||||
updated_at: 1000,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token(
|
||||
&format!("/api/conversations/{conv_id}/artifacts"),
|
||||
&token,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
let items = json["data"].as_array().unwrap();
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0]["id"], artifact_id);
|
||||
assert_eq!(items[0]["kind"], "skill_suggest");
|
||||
assert_eq!(items[0]["status"], "active");
|
||||
|
||||
let patch_req = common::json_with_token(
|
||||
"PATCH",
|
||||
&format!("/api/conversations/{conv_id}/artifacts/{artifact_id}"),
|
||||
json!({ "status": "dismissed" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let patch_resp = app.oneshot(patch_req).await.unwrap();
|
||||
assert_eq!(patch_resp.status(), StatusCode::OK);
|
||||
let patch_json = body_json(patch_resp).await;
|
||||
assert_eq!(patch_json["data"]["status"], "dismissed");
|
||||
}
|
||||
|
||||
// ── T9: Message search ────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t9_1_search_keyword_match() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Search Conv").await;
|
||||
insert_message(&services, conv_id, "msg-1", "Rust is great", 1000).await;
|
||||
insert_message(&services, conv_id, "msg-2", "Python is also nice", 2000).await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_token("/api/messages/search?keyword=Rust", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
let items = json["data"]["items"].as_array().unwrap();
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0]["conversation"]["name"], "Search Conv");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t9_2_search_no_match() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "No Match Conv").await;
|
||||
insert_message(&services, conv_id, "msg-1", "Hello world", 1000).await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_token("/api/messages/search?keyword=xxxxnotexist", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["items"].as_array().unwrap().len(), 0);
|
||||
assert_eq!(json["data"]["total"], 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t9_3_search_pagination() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Search Paged").await;
|
||||
for i in 0..5 {
|
||||
insert_message(
|
||||
&services,
|
||||
conv_id,
|
||||
&format!("msg-{i}"),
|
||||
&format!("Matching keyword {i}"),
|
||||
1000 + i * 100,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token(
|
||||
"/api/messages/search?keyword=Matching&page=1&page_size=2",
|
||||
&token,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["items"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(json["data"]["total"], 5);
|
||||
assert_eq!(json["data"]["has_more"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t9_4_search_empty_keyword() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_token("/api/messages/search?keyword=", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t9_5_search_requires_auth() {
|
||||
let (app, _services) = build_app().await;
|
||||
let resp = app
|
||||
.oneshot(get_request("/api/messages/search?keyword=test"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// ── T12.4: SQL injection safety ───────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t12_4_search_sql_injection_safe() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_token(
|
||||
"/api/messages/search?keyword=';%20DROP%20TABLE%20messages;%20--",
|
||||
&token,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
// Should not crash; just return empty results
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["items"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
// ── Message response field validation ─────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn message_response_has_correct_fields() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Field Check").await;
|
||||
insert_message(&services, conv_id, "msg-fc", "Content check", 5000).await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_token(
|
||||
&format!("/api/conversations/{conv_id}/messages"),
|
||||
&token,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
let msg = &json["data"]["items"][0];
|
||||
|
||||
// Verify snake_case fields exist
|
||||
assert!(msg.get("id").is_some());
|
||||
assert!(msg.get("conversation_id").is_some());
|
||||
assert!(msg.get("type").is_some());
|
||||
assert!(msg.get("content").is_some());
|
||||
assert!(msg.get("position").is_some());
|
||||
assert!(msg.get("status").is_some());
|
||||
assert!(msg.get("created_at").is_some());
|
||||
// Verify no camelCase leaks
|
||||
assert!(msg.get("conversationId").is_none());
|
||||
assert!(msg.get("createdAt").is_none());
|
||||
assert!(msg.get("msgId").is_none());
|
||||
}
|
||||
|
||||
// ── Delete cascades messages ──────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_conversation_cascades_messages() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Cascade Test").await;
|
||||
insert_message(&services, conv_id, "msg-cas-1", "msg 1", 1000).await;
|
||||
insert_message(&services, conv_id, "msg-cas-2", "msg 2", 2000).await;
|
||||
|
||||
// Delete the conversation
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(common::delete_with_token(
|
||||
&format!("/api/conversations/{conv_id}"),
|
||||
&token,
|
||||
&csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// Search for messages from the deleted conversation should return nothing
|
||||
let resp = app
|
||||
.oneshot(get_with_token("/api/messages/search?keyword=msg", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["items"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
// ── Cross-conversation search ─────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_across_multiple_conversations() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let conv1 = create_conversation(&mut app, &token, &csrf, "Conv Alpha").await;
|
||||
let conv2 = create_conversation(&mut app, &token, &csrf, "Conv Beta").await;
|
||||
|
||||
insert_message(&services, conv1, "msg-a1", "Rust review needed", 1000).await;
|
||||
insert_message(&services, conv2, "msg-b1", "Rust performance tips", 2000).await;
|
||||
insert_message(&services, conv2, "msg-b2", "Python patterns", 3000).await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_token("/api/messages/search?keyword=Rust", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
let items = json["data"]["items"].as_array().unwrap();
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(json["data"]["total"], 2);
|
||||
}
|
||||
|
||||
// ── T2.1: Send message ──────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_1_send_message_accepted() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Send Test").await;
|
||||
|
||||
let body = json!({ "content": "Hello AI" });
|
||||
let req = common::json_with_token(
|
||||
"POST",
|
||||
&format!("/api/conversations/{conv_id}/messages"),
|
||||
body,
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
// The stub agent factory returns an error, so we expect 500
|
||||
// (the route itself is wired correctly — 202 when factory is real)
|
||||
// In E2E with stub factory, the get_or_build_task fails.
|
||||
// We verify the route is reachable and returns an error (not 404/405).
|
||||
// 400 may occur when the stub environment lacks valid backend configuration.
|
||||
let status = resp.status();
|
||||
assert!(
|
||||
status == StatusCode::ACCEPTED
|
||||
|| status == StatusCode::INTERNAL_SERVER_ERROR
|
||||
|| status == StatusCode::BAD_REQUEST,
|
||||
"Expected 202, 400, or 500 (stub factory), got {status}"
|
||||
);
|
||||
|
||||
if status == StatusCode::ACCEPTED {
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_slice(&axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap()).unwrap();
|
||||
assert!(body["success"].as_bool().unwrap());
|
||||
assert!(body["data"]["msg_id"].as_str().is_some_and(|s| !s.is_empty()));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_1_send_message_empty_content_bad_request() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Empty Content").await;
|
||||
|
||||
let body = json!({ "content": "" });
|
||||
let req = common::json_with_token(
|
||||
"POST",
|
||||
&format!("/api/conversations/{conv_id}/messages"),
|
||||
body,
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_1_send_message_conversation_not_found() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let body = json!({ "content": "Hello" });
|
||||
let req = common::json_with_token("POST", "/api/conversations/non-existent/messages", body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_1b_send_message_pathological_workspace_returns_runtime_whitespace_code() {
|
||||
let (mut app, services) = build_app_with_mock_agents().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Legacy Workspace").await;
|
||||
update_conversation_workspace(&services, conv_id, "/tmp/my project ").await;
|
||||
|
||||
let body = json!({ "content": "Hello" });
|
||||
let req = common::json_with_token(
|
||||
"POST",
|
||||
&format!("/api/conversations/{conv_id}/messages"),
|
||||
body,
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["code"], "WORKSPACE_PATH_EDGE_WHITESPACE_RUNTIME_UNSUPPORTED");
|
||||
assert_eq!(json["details"]["workspace_path"], "/tmp/my project ");
|
||||
assert_eq!(json["details"]["operation"], "runtime");
|
||||
}
|
||||
|
||||
/// Regression for the macOS per-user data dir: `~/Library/Application
|
||||
/// Support/NomiFun/Nomi/conversations/...` contains interior whitespace and
|
||||
/// every conversation auto-provisioned under it must remain sendable.
|
||||
#[tokio::test]
|
||||
async fn t2_1c_send_message_accepts_interior_whitespace_workspace() {
|
||||
let (mut app, services) = build_app_with_mock_agents().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "macOS Workspace").await;
|
||||
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let workspace = temp.path().join("Application Support").join("Nomi").join("conversations").join("nomi-temp-1");
|
||||
std::fs::create_dir_all(&workspace).unwrap();
|
||||
update_conversation_workspace(&services, conv_id, &workspace.to_string_lossy()).await;
|
||||
|
||||
let body = json!({ "content": "Hello" });
|
||||
let req = common::json_with_token(
|
||||
"POST",
|
||||
&format!("/api/conversations/{conv_id}/messages"),
|
||||
body,
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::ACCEPTED);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["success"].as_bool().unwrap());
|
||||
assert!(json["data"]["msg_id"].as_str().is_some_and(|s| !s.is_empty()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_1_send_message_requires_auth() {
|
||||
let (app, _services) = build_app().await;
|
||||
|
||||
let body = json!({ "content": "Hello" });
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/conversations/some-id/messages")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&body).unwrap()))
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// ── T2.2: Stop stream ───────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_2_stop_stream_conversation_not_found() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = common::json_with_token(
|
||||
"POST",
|
||||
"/api/conversations/non-existent/cancel",
|
||||
json!({}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_2_stop_stream_requires_auth() {
|
||||
let (app, _services) = build_app().await;
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/conversations/some-id/cancel")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// ── T2.3: Warmup ────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_3_warmup_conversation_not_found() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = common::json_with_token(
|
||||
"POST",
|
||||
"/api/conversations/non-existent/warmup",
|
||||
json!({}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_3b_warmup_pathological_workspace_returns_runtime_whitespace_code() {
|
||||
let (mut app, services) = build_app_with_mock_agents().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv_id = create_conversation(&mut app, &token, &csrf, "Legacy Warmup").await;
|
||||
update_conversation_workspace(&services, conv_id, "/tmp/my project ").await;
|
||||
|
||||
let req = common::json_with_token(
|
||||
"POST",
|
||||
&format!("/api/conversations/{conv_id}/warmup"),
|
||||
json!({}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["code"], "WORKSPACE_PATH_EDGE_WHITESPACE_RUNTIME_UNSUPPORTED");
|
||||
assert_eq!(json["details"]["workspace_path"], "/tmp/my project ");
|
||||
assert_eq!(json["details"]["operation"], "runtime");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_3_warmup_requires_auth() {
|
||||
let (app, _services) = build_app().await;
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/conversations/some-id/warmup")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Phase-3 model-failover config route tests (review #6/#12): GET defaults to
|
||||
//! disabled, PUT round-trips the queue, and the path matches the frontend
|
||||
//! `agentModelFailover` (`/api/agent/model-failover`).
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use common::{body_json, build_app, get_with_token, json_with_token, setup_and_login};
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_failover_get_defaults_to_disabled_with_auth() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_token("/api/agent/model-failover", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
// Unset pref → ModelFailoverConfig::default() = disabled.
|
||||
assert_eq!(json["data"]["enabled"], false);
|
||||
assert_eq!(json["data"]["queue"], json!([]));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_failover_put_then_get_roundtrips_with_auth() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let cfg = json!({
|
||||
"enabled": true,
|
||||
"queue": [
|
||||
{"provider_id": "p1", "model": "m1"},
|
||||
{"provider_id": "p2", "model": "m2"}
|
||||
],
|
||||
"max_switches": 3,
|
||||
"stamp_unhealthy": false
|
||||
});
|
||||
|
||||
let req = json_with_token("PUT", "/api/agent/model-failover", cfg.clone(), &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
// PUT echoes the saved config back.
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["enabled"], true);
|
||||
assert_eq!(json["data"]["max_switches"], 3);
|
||||
assert_eq!(json["data"]["queue"][1]["provider_id"], "p2");
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_token("/api/agent/model-failover", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["enabled"], true);
|
||||
assert_eq!(json["data"]["stamp_unhealthy"], false);
|
||||
assert_eq!(json["data"]["queue"][0]["model"], "m1");
|
||||
assert_eq!(json["data"]["queue"][1]["model"], "m2");
|
||||
}
|
||||
@@ -0,0 +1,662 @@
|
||||
//! E2E tests for office HTTP endpoints.
|
||||
//!
|
||||
//! Covers test-plan items:
|
||||
//! - AU-1/AU-2: Unauthenticated access rejected
|
||||
//! - SH-1..SH-7: Snapshot CRUD (save, list, get-content, not-found, trim, isolation, target combos)
|
||||
//! - SO-1/SO-2: Star Office detection (no service available)
|
||||
//! - DC-1/DC-4/DC-9: Document conversion (Excel→JSON, file not found, invalid target)
|
||||
//! - RP-2/RP-4: Proxy SSRF protection (inactive port rejected)
|
||||
//! - WP-4: Word preview start when officecli not available
|
||||
//!
|
||||
//! Items requiring real officecli or mock HTTP backends (WP-1..3, WP-5..6, EP-1..2,
|
||||
//! PP-1..3, RP-1/RP-3, RP-5..7, SO-5..6, DC-5..8) are tested at the service
|
||||
//! integration level in `nomifun-office/tests/`.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use common::{body_json, get_request, json_with_token, setup_and_login};
|
||||
|
||||
use nomifun_app::{AppConfig, AppServices, build_module_states, create_router_with_states};
|
||||
use nomifun_office::{
|
||||
ConversionService, OfficeRouterState, OfficecliWatchManager, ProxyService, SnapshotService, StarOfficeDetector,
|
||||
};
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
async fn build_office_app() -> (axum::Router, AppServices, tempfile::TempDir) {
|
||||
let default_roots = vec![
|
||||
std::env::temp_dir(),
|
||||
dirs::home_dir().unwrap_or_else(std::env::temp_dir),
|
||||
];
|
||||
build_office_app_with_roots(default_roots).await
|
||||
}
|
||||
|
||||
async fn build_office_app_with_roots(
|
||||
allowed_roots: Vec<std::path::PathBuf>,
|
||||
) -> (axum::Router, AppServices, tempfile::TempDir) {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let data_dir = tmp.path().to_path_buf();
|
||||
|
||||
let db = nomifun_db::init_database_memory().await.unwrap();
|
||||
let config = AppConfig {
|
||||
data_dir: data_dir.clone(),
|
||||
work_dir: data_dir,
|
||||
..Default::default()
|
||||
};
|
||||
let services = AppServices::from_config(db, &config).await.unwrap();
|
||||
let (mut states, _) = build_module_states(&services).await;
|
||||
|
||||
states.office = build_test_office_state(tmp.path(), allowed_roots);
|
||||
|
||||
let router = create_router_with_states(&services, states);
|
||||
(router, services, tmp)
|
||||
}
|
||||
|
||||
fn build_test_office_state(data_dir: &std::path::Path, allowed_roots: Vec<std::path::PathBuf>) -> OfficeRouterState {
|
||||
use nomifun_office::error::OfficeError;
|
||||
use nomifun_office::types::DocType;
|
||||
use nomifun_office::{ProcessHandle, ProcessSpawner};
|
||||
|
||||
struct NoopSpawner;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ProcessSpawner for NoopSpawner {
|
||||
async fn spawn_officecli(
|
||||
&self,
|
||||
_file_path: &str,
|
||||
_port: u16,
|
||||
_doc_type: DocType,
|
||||
) -> Result<Box<dyn ProcessHandle>, OfficeError> {
|
||||
Err(OfficeError::OfficecliNotFound)
|
||||
}
|
||||
async fn install_officecli(&self) -> Result<(), OfficeError> {
|
||||
Err(OfficeError::InstallFailed("not available in test".into()))
|
||||
}
|
||||
async fn is_officecli_installed(&self) -> bool {
|
||||
false
|
||||
}
|
||||
async fn check_update(&self, _doc_type: DocType) -> Result<(), OfficeError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct NoopBroadcaster;
|
||||
impl nomifun_realtime::EventBroadcaster for NoopBroadcaster {
|
||||
fn broadcast(&self, _msg: nomifun_api_types::WebSocketMessage<serde_json::Value>) {}
|
||||
}
|
||||
|
||||
let spawner: Arc<dyn ProcessSpawner> = Arc::new(NoopSpawner);
|
||||
let bc: Arc<dyn nomifun_realtime::EventBroadcaster> = Arc::new(NoopBroadcaster);
|
||||
let wm = Arc::new(OfficecliWatchManager::new(spawner, bc));
|
||||
|
||||
let snapshot = Arc::new(SnapshotService::new(data_dir));
|
||||
let detector = Arc::new(StarOfficeDetector::new(reqwest::Client::new()));
|
||||
let conversion = Arc::new(ConversionService::new(None));
|
||||
let proxy = Arc::new(ProxyService::new(wm.clone()));
|
||||
|
||||
OfficeRouterState {
|
||||
watch_manager: wm,
|
||||
snapshot_service: snapshot,
|
||||
star_office_detector: detector,
|
||||
conversion_service: conversion,
|
||||
proxy_service: proxy,
|
||||
allowed_roots,
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_target() -> serde_json::Value {
|
||||
json!({"content_type": "markdown", "file_path": "/a.md"})
|
||||
}
|
||||
|
||||
// ── AU-1/AU-2: Unauthenticated requests ─────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn au1_unauthenticated_preview_start_returns_403() {
|
||||
let (app, _services, _tmp) = build_office_app().await;
|
||||
let req = common::get_request("/api/word-preview/start");
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert!(
|
||||
resp.status() == StatusCode::UNAUTHORIZED || resp.status() == StatusCode::FORBIDDEN,
|
||||
"expected 401 or 403, got {}",
|
||||
resp.status()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn au2_unauthenticated_all_office_endpoints() {
|
||||
let endpoints = [
|
||||
"/api/word-preview/start",
|
||||
"/api/excel-preview/start",
|
||||
"/api/ppt-preview/start",
|
||||
"/api/preview-history/list",
|
||||
"/api/preview-history/save",
|
||||
"/api/star-office/detect",
|
||||
"/api/document/convert",
|
||||
];
|
||||
|
||||
for endpoint in endpoints {
|
||||
let (app, _services, _tmp) = build_office_app().await;
|
||||
let body = json!({});
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri(endpoint)
|
||||
.header("content-type", "application/json")
|
||||
.body(axum::body::Body::from(serde_json::to_vec(&body).unwrap()))
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert!(
|
||||
resp.status() == StatusCode::UNAUTHORIZED || resp.status() == StatusCode::FORBIDDEN,
|
||||
"endpoint {endpoint}: expected 401 or 403, got {}",
|
||||
resp.status()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── WP-4: Word preview start (officecli not available) ───────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn wp4_word_preview_officecli_not_available() {
|
||||
let (mut app, services, tmp) = build_office_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await;
|
||||
|
||||
let file_path = tmp.path().join("test.docx");
|
||||
std::fs::write(&file_path, b"docx").unwrap();
|
||||
|
||||
let body = json!({"file_path": file_path.to_str().unwrap()});
|
||||
let req = json_with_token("POST", "/api/word-preview/start", body, &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["success"], true);
|
||||
let url = json["data"]["url"].as_str().unwrap();
|
||||
assert!(url.is_empty(), "url should be empty when officecli unavailable");
|
||||
assert_eq!(json["data"]["error"], "OFFICECLI_INSTALL_FAILED");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wp5_word_preview_with_workspace_accepts_non_sandbox_path() {
|
||||
let sandbox = tempfile::tempdir().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
let file_path = outside.path().join("demo.docx");
|
||||
std::fs::write(&file_path, b"docx").unwrap();
|
||||
|
||||
let (mut app, services, _tmp) = build_office_app_with_roots(vec![sandbox.path().to_path_buf()]).await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "user2", "pass123").await;
|
||||
|
||||
let body = json!({
|
||||
"file_path": file_path.to_str().unwrap(),
|
||||
"workspace": outside.path().to_str().unwrap()
|
||||
});
|
||||
let req = json_with_token("POST", "/api/word-preview/start", body, &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["success"], true);
|
||||
assert_eq!(json["data"]["error"], "OFFICECLI_INSTALL_FAILED");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wp6_word_preview_without_workspace_rejects_non_sandbox_path() {
|
||||
let sandbox = tempfile::tempdir().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
let file_path = outside.path().join("demo.docx");
|
||||
std::fs::write(&file_path, b"docx").unwrap();
|
||||
|
||||
let (mut app, services, _tmp) = build_office_app_with_roots(vec![sandbox.path().to_path_buf()]).await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "user3", "pass123").await;
|
||||
|
||||
let body = json!({
|
||||
"file_path": file_path.to_str().unwrap()
|
||||
});
|
||||
let req = json_with_token("POST", "/api/word-preview/start", body, &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["code"], "PATH_OUTSIDE_SANDBOX");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ep1_excel_preview_with_workspace_accepts_non_sandbox_path() {
|
||||
let sandbox = tempfile::tempdir().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
let file_path = outside.path().join("demo.xlsx");
|
||||
std::fs::write(&file_path, b"xlsx").unwrap();
|
||||
|
||||
let (mut app, services, _tmp) = build_office_app_with_roots(vec![sandbox.path().to_path_buf()]).await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "user4", "pass123").await;
|
||||
|
||||
let body = json!({
|
||||
"file_path": file_path.to_str().unwrap(),
|
||||
"workspace": outside.path().to_str().unwrap()
|
||||
});
|
||||
let req = json_with_token("POST", "/api/excel-preview/start", body, &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["success"], true);
|
||||
assert_eq!(json["data"]["error"], "OFFICECLI_INSTALL_FAILED");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pp1_ppt_preview_with_workspace_accepts_non_sandbox_path() {
|
||||
let sandbox = tempfile::tempdir().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
let file_path = outside.path().join("demo.pptx");
|
||||
std::fs::write(&file_path, b"pptx").unwrap();
|
||||
|
||||
let (mut app, services, _tmp) = build_office_app_with_roots(vec![sandbox.path().to_path_buf()]).await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "user5", "pass123").await;
|
||||
|
||||
let body = json!({
|
||||
"file_path": file_path.to_str().unwrap(),
|
||||
"workspace": outside.path().to_str().unwrap()
|
||||
});
|
||||
let req = json_with_token("POST", "/api/ppt-preview/start", body, &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["success"], true);
|
||||
assert_eq!(json["data"]["error"], "OFFICECLI_INSTALL_FAILED");
|
||||
}
|
||||
|
||||
// ── SH-1: Save snapshot ─────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn sh1_save_snapshot() {
|
||||
let (mut app, services, _tmp) = build_office_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await;
|
||||
|
||||
let body = json!({
|
||||
"target": snapshot_target(),
|
||||
"content": "# Hello World"
|
||||
});
|
||||
let req = json_with_token("POST", "/api/preview-history/save", body, &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["success"], true);
|
||||
|
||||
let data = &json["data"];
|
||||
assert!(data["id"].is_string());
|
||||
assert!(!data["id"].as_str().unwrap().is_empty());
|
||||
assert!(data["created_at"].is_number());
|
||||
assert_eq!(data["size"], 13); // "# Hello World".len()
|
||||
assert_eq!(data["content_type"], "markdown");
|
||||
}
|
||||
|
||||
// ── SH-2: List snapshots ────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn sh2_list_snapshots() {
|
||||
let (mut app, services, _tmp) = build_office_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await;
|
||||
|
||||
for i in 0..3 {
|
||||
let body = json!({
|
||||
"target": snapshot_target(),
|
||||
"content": format!("content {i}")
|
||||
});
|
||||
let req = json_with_token("POST", "/api/preview-history/save", body, &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
let body = json!({"target": snapshot_target()});
|
||||
let req = json_with_token("POST", "/api/preview-history/list", body, &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["success"], true);
|
||||
|
||||
let snapshots = json["data"].as_array().unwrap();
|
||||
assert_eq!(snapshots.len(), 3);
|
||||
}
|
||||
|
||||
// ── SH-3: Get snapshot content ──────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn sh3_get_snapshot_content() {
|
||||
let (mut app, services, _tmp) = build_office_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await;
|
||||
|
||||
let save_body = json!({
|
||||
"target": snapshot_target(),
|
||||
"content": "# Hello"
|
||||
});
|
||||
let req = json_with_token("POST", "/api/preview-history/save", save_body, &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let save_json = body_json(resp).await;
|
||||
let snapshot_id = save_json["data"]["id"].as_str().unwrap();
|
||||
|
||||
let get_body = json!({
|
||||
"target": snapshot_target(),
|
||||
"snapshot_id": snapshot_id
|
||||
});
|
||||
let req = json_with_token("POST", "/api/preview-history/get-content", get_body, &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["success"], true);
|
||||
assert_eq!(json["data"]["content"], "# Hello");
|
||||
assert_eq!(json["data"]["snapshot"]["id"], snapshot_id);
|
||||
}
|
||||
|
||||
// ── SH-4: Get nonexistent snapshot ──────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn sh4_get_nonexistent_snapshot() {
|
||||
let (mut app, services, _tmp) = build_office_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await;
|
||||
|
||||
let body = json!({
|
||||
"target": snapshot_target(),
|
||||
"snapshot_id": "nonexistent"
|
||||
});
|
||||
let req = json_with_token("POST", "/api/preview-history/get-content", body, &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["success"], true);
|
||||
assert!(json["data"].is_null());
|
||||
}
|
||||
|
||||
// ── SH-5: Snapshot trimming at 50 limit ─────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn sh5_snapshot_trim_at_limit() {
|
||||
let (mut app, services, _tmp) = build_office_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await;
|
||||
|
||||
for i in 0..52 {
|
||||
let body = json!({
|
||||
"target": snapshot_target(),
|
||||
"content": format!("snap {i}")
|
||||
});
|
||||
let req = json_with_token("POST", "/api/preview-history/save", body, &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
let body = json!({"target": snapshot_target()});
|
||||
let req = json_with_token("POST", "/api/preview-history/list", body, &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
let json = body_json(resp).await;
|
||||
let snapshots = json["data"].as_array().unwrap();
|
||||
assert!(
|
||||
snapshots.len() <= 50,
|
||||
"expected at most 50 snapshots, got {}",
|
||||
snapshots.len()
|
||||
);
|
||||
}
|
||||
|
||||
// ── SH-6: Different targets are isolated ────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn sh6_different_targets_isolated() {
|
||||
let (mut app, services, _tmp) = build_office_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await;
|
||||
|
||||
let target_a = json!({"content_type": "markdown", "file_path": "/a.md"});
|
||||
let target_b = json!({"content_type": "code", "file_path": "/b.rs"});
|
||||
|
||||
let body_a = json!({"target": target_a, "content": "AAA"});
|
||||
let req = json_with_token("POST", "/api/preview-history/save", body_a, &token, &csrf);
|
||||
app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
let body_b = json!({"target": target_b, "content": "BBB"});
|
||||
let req = json_with_token("POST", "/api/preview-history/save", body_b, &token, &csrf);
|
||||
app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
let list_a = json!({"target": target_a});
|
||||
let req = json_with_token("POST", "/api/preview-history/list", list_a, &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let json_a = body_json(resp).await;
|
||||
assert_eq!(json_a["data"].as_array().unwrap().len(), 1);
|
||||
|
||||
let list_b = json!({"target": target_b});
|
||||
let req = json_with_token("POST", "/api/preview-history/list", list_b, &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let json_b = body_json(resp).await;
|
||||
assert_eq!(json_b["data"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
// ── SH-7: Target with multiple fields produces different hash ───────
|
||||
|
||||
#[tokio::test]
|
||||
async fn sh7_target_field_combination_different_hash() {
|
||||
let (mut app, services, _tmp) = build_office_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await;
|
||||
|
||||
let target_simple = json!({"content_type": "markdown", "file_path": "/a.md"});
|
||||
let target_complex = json!({
|
||||
"content_type": "markdown",
|
||||
"file_path": "/a.md",
|
||||
"workspace": "/ws",
|
||||
"conversation_id": 1
|
||||
});
|
||||
|
||||
let body = json!({"target": target_simple, "content": "simple"});
|
||||
let req = json_with_token("POST", "/api/preview-history/save", body, &token, &csrf);
|
||||
app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
let body = json!({"target": target_complex, "content": "complex"});
|
||||
let req = json_with_token("POST", "/api/preview-history/save", body, &token, &csrf);
|
||||
app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
let list = json!({"target": target_simple});
|
||||
let req = json_with_token("POST", "/api/preview-history/list", list, &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(
|
||||
json["data"].as_array().unwrap().len(),
|
||||
1,
|
||||
"simple target should only have 1 snapshot"
|
||||
);
|
||||
|
||||
let list = json!({"target": target_complex});
|
||||
let req = json_with_token("POST", "/api/preview-history/list", list, &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(
|
||||
json["data"].as_array().unwrap().len(),
|
||||
1,
|
||||
"complex target should only have 1 snapshot"
|
||||
);
|
||||
}
|
||||
|
||||
// ── SO-1: Star Office detect — no service available ─────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn so1_detect_no_service() {
|
||||
let (mut app, services, _tmp) = build_office_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await;
|
||||
|
||||
let body = json!({});
|
||||
let req = json_with_token("POST", "/api/star-office/detect", body, &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["success"], true);
|
||||
assert!(json["data"]["url"].is_null());
|
||||
}
|
||||
|
||||
// ── SO-2: Star Office detect with preferred URL ─────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn so2_detect_with_preferred_url() {
|
||||
let (mut app, services, _tmp) = build_office_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await;
|
||||
|
||||
let body = json!({"preferred_url": "http://localhost:19000"});
|
||||
let req = json_with_token("POST", "/api/star-office/detect", body, &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["success"], true);
|
||||
assert!(json["data"]["url"].is_null());
|
||||
}
|
||||
|
||||
// ── DC-1: Excel → JSON ──────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn dc1_excel_to_json() {
|
||||
let (mut app, services, tmp) = build_office_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await;
|
||||
|
||||
let xlsx_path = tmp.path().join("test.xlsx");
|
||||
create_test_xlsx(&xlsx_path);
|
||||
|
||||
let body = json!({
|
||||
"file_path": xlsx_path.to_str().unwrap(),
|
||||
"to": "excel-json"
|
||||
});
|
||||
let req = json_with_token("POST", "/api/document/convert", body, &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["success"], true);
|
||||
assert_eq!(json["data"]["to"], "excel-json");
|
||||
assert_eq!(json["data"]["result"]["success"], true);
|
||||
|
||||
let sheets = json["data"]["result"]["data"]["sheets"].as_array().unwrap();
|
||||
assert!(!sheets.is_empty());
|
||||
assert!(sheets[0]["name"].is_string());
|
||||
assert!(sheets[0]["data"].is_array());
|
||||
}
|
||||
|
||||
// ── DC-4: Excel → JSON (file not found) ─────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn dc4_excel_file_not_found() {
|
||||
let (mut app, services, _tmp) = build_office_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await;
|
||||
|
||||
let body = json!({
|
||||
"file_path": "/nonexistent/file.xlsx",
|
||||
"to": "excel-json"
|
||||
});
|
||||
let req = json_with_token("POST", "/api/document/convert", body, &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["code"], "BAD_REQUEST");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dc5_document_convert_rejects_outside_sandbox() {
|
||||
let sandbox = tempfile::tempdir().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
let xlsx_path = outside.path().join("test.xlsx");
|
||||
create_test_xlsx(&xlsx_path);
|
||||
|
||||
let (mut app, services, _tmp) = build_office_app_with_roots(vec![sandbox.path().to_path_buf()]).await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "user6", "pass123").await;
|
||||
|
||||
let body = json!({
|
||||
"file_path": xlsx_path.to_str().unwrap(),
|
||||
"to": "excel-json"
|
||||
});
|
||||
let req = json_with_token("POST", "/api/document/convert", body, &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["code"], "PATH_OUTSIDE_SANDBOX");
|
||||
}
|
||||
|
||||
// ── DC-9: Invalid conversion target ─────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn dc9_invalid_conversion_target() {
|
||||
let (mut app, services, _tmp) = build_office_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await;
|
||||
|
||||
let body = json!({
|
||||
"file_path": "/path/to/file.txt",
|
||||
"to": "invalid"
|
||||
});
|
||||
let req = json_with_token("POST", "/api/document/convert", body, &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// ── RP-2: PPT proxy SSRF protection ─────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn rp2_ppt_proxy_ssrf_inactive_port() {
|
||||
let (app, _services, _tmp) = build_office_app().await;
|
||||
|
||||
let req = get_request("/api/ppt-proxy/8080/index.html");
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// ── RP-4: Office watch proxy SSRF protection ────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn rp4_office_watch_proxy_ssrf_inactive_port() {
|
||||
let (app, _services, _tmp) = build_office_app().await;
|
||||
|
||||
let req = get_request("/api/office-watch-proxy/9999/index.html");
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// ── RP-root: proxy root path (no trailing path segment) ─────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn ppt_proxy_root_path_returns_non_404() {
|
||||
let (app, _services, _tmp) = build_office_app().await;
|
||||
|
||||
let req = get_request("/api/ppt-proxy/19999");
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn office_watch_proxy_root_path_returns_non_404() {
|
||||
let (app, _services, _tmp) = build_office_app().await;
|
||||
|
||||
let req = get_request("/api/office-watch-proxy/19999");
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// ── Test utilities ──────────────────────────────────────────────────
|
||||
|
||||
fn create_test_xlsx(path: &std::path::Path) {
|
||||
use rust_xlsxwriter::Workbook;
|
||||
|
||||
let mut workbook = Workbook::new();
|
||||
let worksheet = workbook.add_worksheet();
|
||||
worksheet.write_string(0, 0, "Name").unwrap();
|
||||
worksheet.write_string(0, 1, "Age").unwrap();
|
||||
worksheet.write_string(1, 0, "Alice").unwrap();
|
||||
worksheet.write_number(1, 1, 30.0).unwrap();
|
||||
workbook.save(path).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! Integration tests for POST /api/terminals/register-knowledge.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use common::{body_json, build_app, json_with_token, setup_and_login};
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_knowledge_claude_writes_mcp_json() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cwd = tmp.path().to_str().unwrap().to_owned();
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/terminals/register-knowledge",
|
||||
json!({ "cwd": cwd, "family": "claude" }),
|
||||
&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["success"], true);
|
||||
assert_eq!(json["data"]["scope"], "project");
|
||||
assert!(json["data"]["written_path"].as_str().unwrap().ends_with(".mcp.json"));
|
||||
|
||||
// Verify file was actually written
|
||||
let content = std::fs::read_to_string(tmp.path().join(".mcp.json")).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
|
||||
assert!(parsed["mcpServers"]["nomifun-knowledge"]["command"].is_string());
|
||||
assert_eq!(
|
||||
parsed["mcpServers"]["nomifun-knowledge"]["args"],
|
||||
json!(["mcp-knowledge-stdio"])
|
||||
);
|
||||
// Must NOT contain token or port
|
||||
let lower = content.to_lowercase();
|
||||
assert!(!lower.contains("token"));
|
||||
assert!(!lower.contains("\"port\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_knowledge_invalid_family_returns_400() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/terminals/register-knowledge",
|
||||
json!({ "cwd": "/tmp", "family": "invalid-cli" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], false);
|
||||
assert!(json["error"].as_str().unwrap().contains("invalid family"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_knowledge_gemini_creates_dir_and_file() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cwd = tmp.path().to_str().unwrap().to_owned();
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/terminals/register-knowledge",
|
||||
json!({ "cwd": cwd, "family": "gemini" }),
|
||||
&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"]["scope"], "project");
|
||||
|
||||
// Verify .gemini/settings.json was written
|
||||
let path = tmp.path().join(".gemini/settings.json");
|
||||
assert!(path.exists());
|
||||
let content = std::fs::read_to_string(&path).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
|
||||
assert!(parsed["mcpServers"]["nomifun-knowledge"]["command"].is_string());
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
//! E2E tests for Remote Agent CRUD, connection test, and handshake endpoints.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use common::{body_json, build_app, delete_with_token, get_with_token, json_with_token, setup_and_login};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
fn bearer_agent_body() -> serde_json::Value {
|
||||
json!({
|
||||
"name": "Test Remote Server",
|
||||
"protocol": "acp",
|
||||
"url": "wss://remote.example.com",
|
||||
"auth_type": "bearer",
|
||||
"auth_token": "my-secret-token-1234",
|
||||
"description": "Production agent"
|
||||
})
|
||||
}
|
||||
|
||||
fn openclaw_agent_body() -> serde_json::Value {
|
||||
json!({
|
||||
"name": "OpenClaw Agent",
|
||||
"protocol": "openclaw",
|
||||
"url": "wss://openclaw.example.com",
|
||||
"auth_type": "none"
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_agent(app: &mut axum::Router, token: &str, csrf: &str, body: serde_json::Value) -> serde_json::Value {
|
||||
let req = json_with_token("POST", "/api/remote-agents", body, token, csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
json
|
||||
}
|
||||
|
||||
// ── 1.1 Create Remote Agent ─────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t1_1_create_bearer_agent() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let json = create_agent(&mut app, &token, &csrf, bearer_agent_body()).await;
|
||||
|
||||
let data = &json["data"];
|
||||
assert!(data["id"].as_i64().is_some());
|
||||
assert_eq!(data["name"], "Test Remote Server");
|
||||
assert_eq!(data["protocol"], "acp");
|
||||
assert_eq!(data["url"], "wss://remote.example.com");
|
||||
assert_eq!(data["auth_type"], "bearer");
|
||||
// Auth token should be masked
|
||||
assert_eq!(data["auth_token"], "***1234");
|
||||
assert_eq!(data["status"], "unknown");
|
||||
assert_eq!(data["description"], "Production agent");
|
||||
assert!(data["created_at"].as_i64().is_some());
|
||||
assert!(data["updated_at"].as_i64().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t1_2_create_openclaw_agent_generates_device_keys() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let json = create_agent(&mut app, &token, &csrf, openclaw_agent_body()).await;
|
||||
|
||||
let data = &json["data"];
|
||||
assert_eq!(data["protocol"], "openclaw");
|
||||
// Device ID and public key should be generated
|
||||
assert!(data["device_id"].as_str().unwrap().starts_with("dev_"));
|
||||
assert!(data["device_public_key"].as_str().is_some());
|
||||
// Private key should NOT be in the response
|
||||
assert!(data.get("device_private_key").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t1_3_create_missing_required_fields() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let body = json!({ "name": "test" });
|
||||
let req = json_with_token("POST", "/api/remote-agents", body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t1_4_create_unauthenticated() {
|
||||
let (app, _services) = build_app().await;
|
||||
|
||||
let body = bearer_agent_body();
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/remote-agents")
|
||||
.header("content-type", "application/json")
|
||||
.body(axum::body::Body::from(serde_json::to_vec(&body).unwrap()))
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// ── 1.2 List Remote Agents ──────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_1_list_returns_agents_without_auth_token() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
create_agent(&mut app, &token, &csrf, bearer_agent_body()).await;
|
||||
|
||||
let req = get_with_token("/api/remote-agents", &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
let data = json["data"].as_array().unwrap();
|
||||
assert_eq!(data.len(), 1);
|
||||
|
||||
// auth_token should NOT appear in list response
|
||||
assert!(data[0].get("auth_token").is_none());
|
||||
assert_eq!(data[0]["name"], "Test Remote Server");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_2_list_empty() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = get_with_token("/api/remote-agents", &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
let data = json["data"].as_array().unwrap();
|
||||
assert!(data.is_empty());
|
||||
}
|
||||
|
||||
// ── 1.3 Get Single Remote Agent ─────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t3_1_get_single_agent_with_masked_token() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let created = create_agent(&mut app, &token, &csrf, bearer_agent_body()).await;
|
||||
let id = created["data"]["id"].as_i64().unwrap();
|
||||
|
||||
let req = get_with_token(&format!("/api/remote-agents/{id}"), &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
let data = &json["data"];
|
||||
assert_eq!(data["id"], id);
|
||||
assert_eq!(data["auth_token"], "***1234");
|
||||
assert_eq!(data["description"], "Production agent");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t3_2_get_nonexistent_agent() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = get_with_token("/api/remote-agents/nonexistent-uuid", &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// ── 1.4 Update Remote Agent ────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t4_1_update_name_only() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let created = create_agent(&mut app, &token, &csrf, bearer_agent_body()).await;
|
||||
let id = created["data"]["id"].as_i64().unwrap();
|
||||
|
||||
let body = json!({ "name": "Updated Name" });
|
||||
let req = json_with_token("PUT", &format!("/api/remote-agents/{id}"), body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["name"], "Updated Name");
|
||||
// Other fields preserved
|
||||
assert_eq!(json["data"]["protocol"], "acp");
|
||||
assert_eq!(json["data"]["url"], "wss://remote.example.com");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t4_2_update_multiple_fields() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let created = create_agent(&mut app, &token, &csrf, bearer_agent_body()).await;
|
||||
let id = created["data"]["id"].as_i64().unwrap();
|
||||
|
||||
let body = json!({
|
||||
"name": "Updated",
|
||||
"url": "wss://new-url.example.com",
|
||||
"auth_token": "new-super-secret-token"
|
||||
});
|
||||
let req = json_with_token("PUT", &format!("/api/remote-agents/{id}"), body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["name"], "Updated");
|
||||
assert_eq!(json["data"]["url"], "wss://new-url.example.com");
|
||||
assert_eq!(json["data"]["auth_token"], "***oken");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t4_3_update_nonexistent_agent() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let body = json!({ "name": "Doesn't Matter" });
|
||||
let req = json_with_token("PUT", "/api/remote-agents/nonexistent-uuid", body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// ── 1.5 Delete Remote Agent ────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t5_1_delete_agent() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let created = create_agent(&mut app, &token, &csrf, bearer_agent_body()).await;
|
||||
let id = created["data"]["id"].as_i64().unwrap();
|
||||
|
||||
let req = delete_with_token(&format!("/api/remote-agents/{id}"), &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// Verify it's gone
|
||||
let req = get_with_token(&format!("/api/remote-agents/{id}"), &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t5_2_delete_nonexistent_agent() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = delete_with_token("/api/remote-agents/nonexistent-uuid", &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// ── 1.6 Connection Test ─────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t6_1_test_connection_invalid_protocol() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let body = json!({
|
||||
"url": "http://example.com",
|
||||
"auth_type": "bearer"
|
||||
});
|
||||
let req = json_with_token("POST", "/api/remote-agents/test-connection", body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t6_2_test_connection_unauthenticated() {
|
||||
let (app, _services) = build_app().await;
|
||||
|
||||
let body = json!({
|
||||
"url": "wss://remote.example.com"
|
||||
});
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/remote-agents/test-connection")
|
||||
.header("content-type", "application/json")
|
||||
.body(axum::body::Body::from(serde_json::to_vec(&body).unwrap()))
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// ── 1.7 Handshake ───────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t7_1_handshake_non_openclaw_protocol() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let created = create_agent(&mut app, &token, &csrf, bearer_agent_body()).await;
|
||||
let id = created["data"]["id"].as_i64().unwrap();
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
&format!("/api/remote-agents/{id}/handshake"),
|
||||
json!({}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t7_2_handshake_nonexistent_agent() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/remote-agents/nonexistent-uuid/handshake",
|
||||
json!({}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// ── Full CRUD lifecycle ─────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_full_crud_lifecycle() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// Create
|
||||
let created = create_agent(&mut app, &token, &csrf, bearer_agent_body()).await;
|
||||
let id = created["data"]["id"].as_i64().unwrap();
|
||||
|
||||
// Read list
|
||||
let req = get_with_token("/api/remote-agents", &token);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"].as_array().unwrap().len(), 1);
|
||||
|
||||
// Read single
|
||||
let req = get_with_token(&format!("/api/remote-agents/{id}"), &token);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// Update
|
||||
let body = json!({ "name": "Renamed Server", "description": "Updated desc" });
|
||||
let req = json_with_token("PUT", &format!("/api/remote-agents/{id}"), body, &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"]["name"], "Renamed Server");
|
||||
assert_eq!(json["data"]["description"], "Updated desc");
|
||||
|
||||
// Delete
|
||||
let req = delete_with_token(&format!("/api/remote-agents/{id}"), &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// Verify deleted
|
||||
let req = get_with_token("/api/remote-agents", &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["data"].as_array().unwrap().is_empty());
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
//! End-to-end smoke test for the Remote capability front door (`/mcp`).
|
||||
//!
|
||||
//! Proves the integration P0 delivers: the MCP endpoint is mounted in the FULL
|
||||
//! app router, is gated by the per-companion access token, and projects the
|
||||
//! gateway Registry's Remote surface. MCP protocol correctness itself is covered
|
||||
//! by rmcp's own tests; here we verify wiring + auth + the surface projection.
|
||||
//!
|
||||
//! The full rmcp Parts→companion_id round-trip (resolved companion_id flowing
|
||||
//! through the MCP `tools/call` dispatch) is proven below by
|
||||
//! `mcp_tools_call_binds_companion`, which drives a real Streamable-HTTP
|
||||
//! handshake (initialize → notifications/initialized → tools/call for
|
||||
//! `nomi_whoami`) and asserts the resolved companion_id appears in the JSON-RPC
|
||||
//! result. The REST test additionally proves the same binding via the `/v1`
|
||||
//! adapter (same dispatch + CallerCtx path).
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use nomifun_gateway::{Registry, Surface};
|
||||
|
||||
/// `/mcp` is mounted in the full app and rejects callers without a valid
|
||||
/// per-companion token; a minted token passes the gate and reaches the MCP service.
|
||||
#[tokio::test]
|
||||
async fn mcp_endpoint_is_mounted_and_token_gated() {
|
||||
let (app, services) = common::build_app().await;
|
||||
|
||||
let init_body = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-03-26",
|
||||
"capabilities": {},
|
||||
"clientInfo": { "name": "smoke", "version": "1.0" }
|
||||
}
|
||||
});
|
||||
let make_req = |token: Option<&str>| {
|
||||
let mut b = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/mcp")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header(header::ACCEPT, "application/json, text/event-stream");
|
||||
if let Some(t) = token {
|
||||
b = b.header(header::AUTHORIZATION, format!("Bearer {t}"));
|
||||
}
|
||||
b.body(Body::from(serde_json::to_vec(&init_body).unwrap())).unwrap()
|
||||
};
|
||||
|
||||
let companion_id = "smoke-companion";
|
||||
let token = "smoke-companion-token";
|
||||
|
||||
// No token → 401 (the front door is closed before reaching the MCP service).
|
||||
let resp = app.clone().oneshot(make_req(None)).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "/mcp must reject missing token");
|
||||
|
||||
// Wrong token → 401.
|
||||
let resp = app.clone().oneshot(make_req(Some("not-the-token"))).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "/mcp must reject a bad token");
|
||||
|
||||
// Mint a per-companion token via the shared validator (same Arc the router
|
||||
// holds) and the request now passes the gate and reaches the MCP service (NOT 401).
|
||||
services
|
||||
.companion_token_validator
|
||||
.insert_token(companion_id.to_string(), nomifun_auth::token_sha256_hex(token));
|
||||
let resp = app.clone().oneshot(make_req(Some(token))).await.unwrap();
|
||||
assert_ne!(
|
||||
resp.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"a valid per-companion token must pass the gate (got {})",
|
||||
resp.status()
|
||||
);
|
||||
|
||||
// Revocation closes it again.
|
||||
services.companion_token_validator.remove_token(companion_id);
|
||||
let resp = app.oneshot(make_req(Some(token))).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "revoked token must be rejected");
|
||||
}
|
||||
|
||||
/// LOAD-BEARING companion-binding proof over the real MCP transport: drive a
|
||||
/// full Streamable-HTTP handshake through the mounted `/mcp` service
|
||||
/// (initialize → notifications/initialized → tools/call for `nomi_whoami`) and
|
||||
/// assert the resolved companion_id ("smoke-companion") appears in the JSON-RPC
|
||||
/// result. This converts the source-verified rmcp `Parts`→`RemoteCompanion`→
|
||||
/// `CallerCtx.companion_id` path into a permanent regression guard: if an
|
||||
/// rmcp/transport change ever broke the `http::request::Parts` downcast in
|
||||
/// `handler.rs::call_tool`, this test would catch it (the result would show a
|
||||
/// null companion_id instead of "smoke-companion").
|
||||
#[tokio::test]
|
||||
async fn mcp_tools_call_binds_companion() {
|
||||
let (app, services) = common::build_app().await;
|
||||
|
||||
let companion_id = "smoke-companion";
|
||||
let token = "smoke-companion-token";
|
||||
services
|
||||
.companion_token_validator
|
||||
.insert_token(companion_id.to_string(), nomifun_auth::token_sha256_hex(token));
|
||||
|
||||
// rmcp Streamable-HTTP requires the POST Accept header to advertise BOTH
|
||||
// application/json and text/event-stream; responses come back as SSE.
|
||||
let post = |session_id: Option<&str>, body: serde_json::Value| {
|
||||
let mut b = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/mcp")
|
||||
.header(header::HOST, "127.0.0.1")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header(header::ACCEPT, "application/json, text/event-stream")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"));
|
||||
if let Some(sid) = session_id {
|
||||
b = b.header("mcp-session-id", sid);
|
||||
}
|
||||
b.body(Body::from(serde_json::to_vec(&body).unwrap())).unwrap()
|
||||
};
|
||||
|
||||
// Read the whole (terminating) SSE body and pull the JSON-RPC payload out of
|
||||
// the first non-empty `data:` line. The stream is prefixed with an SSE
|
||||
// "priming" event whose data is empty (used for client reconnection), so we
|
||||
// skip empty payloads. The transport closes the request-wise stream once the
|
||||
// response is delivered, so `to_bytes` returns.
|
||||
async fn read_sse_json(resp: axum::response::Response) -> serde_json::Value {
|
||||
let body = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
let data = text
|
||||
.lines()
|
||||
.filter_map(|l| l.strip_prefix("data:").map(str::trim))
|
||||
.find(|d| !d.is_empty())
|
||||
.unwrap_or_else(|| panic!("SSE body had no non-empty data: line; got: {text}"));
|
||||
serde_json::from_str(data).unwrap_or_else(|e| panic!("data line not JSON ({e}): {data}"))
|
||||
}
|
||||
|
||||
// 1) initialize → captures the Mcp-Session-Id response header.
|
||||
let init = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-03-26",
|
||||
"capabilities": {},
|
||||
"clientInfo": { "name": "smoke", "version": "1.0" }
|
||||
}
|
||||
});
|
||||
let resp = app.clone().oneshot(post(None, init)).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK, "initialize should succeed");
|
||||
let session_id = resp
|
||||
.headers()
|
||||
.get("mcp-session-id")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.expect("initialize must return an Mcp-Session-Id header")
|
||||
.to_string();
|
||||
// Drain the initialize SSE response so the session worker advances.
|
||||
let init_result = read_sse_json(resp).await;
|
||||
assert_eq!(init_result["id"], 1, "initialize response echoes id 1");
|
||||
|
||||
// 2) notifications/initialized → 202 Accepted (no body of interest).
|
||||
let initialized = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/initialized"
|
||||
});
|
||||
let resp = app.clone().oneshot(post(Some(&session_id), initialized)).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::ACCEPTED, "initialized notification should be accepted");
|
||||
|
||||
// 3) tools/call nomi_whoami → the result must echo the bound companion_id.
|
||||
let call = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/call",
|
||||
"params": { "name": "nomi_whoami", "arguments": {} }
|
||||
});
|
||||
let resp = app.clone().oneshot(post(Some(&session_id), call)).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK, "tools/call should succeed");
|
||||
let rpc = read_sse_json(resp).await;
|
||||
let payload = serde_json::to_string(&rpc).unwrap();
|
||||
assert!(
|
||||
payload.contains(companion_id),
|
||||
"nomi_whoami result over /mcp must echo the bound companion_id '{companion_id}'; \
|
||||
this proves Parts→RemoteCompanion→CallerCtx.companion_id reached MCP dispatch. Got: {payload}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The REST /v1 adapter is mounted in the full app, token-gated, and serves the
|
||||
/// registry-generated catalog + OpenAPI.
|
||||
#[tokio::test]
|
||||
async fn rest_v1_endpoint_is_mounted_and_gated() {
|
||||
let (app, services) = common::build_app().await;
|
||||
|
||||
// No token → 401 on a /v1 call.
|
||||
let no_tok = Request::builder().method("GET").uri("/v1/tools").body(Body::empty()).unwrap();
|
||||
let resp = app.clone().oneshot(no_tok).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "/v1 must reject missing token");
|
||||
|
||||
let companion_id = "smoke-companion";
|
||||
let token = "smoke-companion-token";
|
||||
services
|
||||
.companion_token_validator
|
||||
.insert_token(companion_id.to_string(), nomifun_auth::token_sha256_hex(token));
|
||||
|
||||
let with_tok = |method: &str, uri: &str| {
|
||||
Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// GET /v1/tools → 200 + non-empty catalog.
|
||||
let resp = app.clone().oneshot(with_tok("GET", "/v1/tools")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
let full_count = v["tools"].as_array().map(|a| a.len()).unwrap_or(0);
|
||||
assert!(full_count > 0, "catalog must be non-empty");
|
||||
|
||||
// P5: ?profile=agent → a strictly narrower curated catalog.
|
||||
let resp = app.clone().oneshot(with_tok("GET", "/v1/tools?profile=agent")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
let agent_count = v["tools"].as_array().map(|a| a.len()).unwrap_or(0);
|
||||
assert!(agent_count > 0 && agent_count < full_count, "agent profile must be a non-empty strict subset");
|
||||
|
||||
// GET /v1/openapi.json → 200 + an OpenAPI doc.
|
||||
let resp = app.clone().oneshot(with_tok("GET", "/v1/openapi.json")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(v["openapi"], "3.1.0");
|
||||
assert!(v["paths"].as_object().map(|p| !p.is_empty()).unwrap_or(false));
|
||||
|
||||
// POST a read-tool with the token → 200 (passes the gate, dispatches).
|
||||
let call = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/v1/tools/nomi_list_conversations")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from("{}"))
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(call).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK, "read tool call should succeed (got {})", resp.status());
|
||||
|
||||
// LOAD-BEARING companion-binding proof: the per-companion token resolves to
|
||||
// `smoke-companion`, and that companion_id must reach dispatch. `nomi_whoami`
|
||||
// (Read cap) echoes the resolved companion_id back, so the response body must
|
||||
// contain "smoke-companion" — proving the token → companion_id → CallerCtx →
|
||||
// tool dispatch round-trip end-to-end through the mounted /v1 adapter.
|
||||
let whoami = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/v1/tools/nomi_whoami")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from("{}"))
|
||||
.unwrap();
|
||||
let resp = app.oneshot(whoami).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK, "nomi_whoami call should succeed (got {})", resp.status());
|
||||
let body = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
assert!(
|
||||
text.contains(companion_id),
|
||||
"nomi_whoami response must echo the resolved companion_id '{companion_id}' (got: {text})"
|
||||
);
|
||||
}
|
||||
|
||||
/// The SSE streaming endpoint dispatches through the registry and terminates
|
||||
/// with a `__result__` frame (verified here with a non-streaming tool; the
|
||||
/// streaming path is exercised the same way, emitting deltas before it).
|
||||
#[tokio::test]
|
||||
async fn rest_v1_stream_endpoint_emits_result_frame() {
|
||||
let (app, services) = common::build_app().await;
|
||||
let token = "smoke-companion-token";
|
||||
services
|
||||
.companion_token_validator
|
||||
.insert_token("smoke-companion".to_string(), nomifun_auth::token_sha256_hex(token));
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/v1/tools/nomi_list_conversations/stream")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from("{}"))
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let ct = resp.headers().get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("");
|
||||
assert!(ct.starts_with("text/event-stream"), "must be an SSE stream, got {ct}");
|
||||
let body = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
assert!(text.contains("__result__"), "SSE stream must end with a __result__ frame; got: {text}");
|
||||
}
|
||||
|
||||
/// The Remote surface projects a non-empty, correctly-gated subset of the
|
||||
/// registry: Destructive tools are visible (Confirm) but Channel hides them,
|
||||
/// and Remote is a subset of the all-permissive Desktop surface.
|
||||
#[test]
|
||||
fn remote_surface_projection_is_correct() {
|
||||
let remote: Vec<&str> = Registry::global().tool_specs(Surface::Remote).iter().map(|s| s.name).collect();
|
||||
let desktop: Vec<&str> = Registry::global().tool_specs(Surface::Desktop).iter().map(|s| s.name).collect();
|
||||
let channel: Vec<&str> = Registry::global().tool_specs(Surface::Channel).iter().map(|s| s.name).collect();
|
||||
|
||||
assert!(!remote.is_empty(), "Remote surface must expose tools");
|
||||
|
||||
// P1: the headline agent-delegation caps are exposed to external callers.
|
||||
assert!(remote.contains(&"nomi_agent_run"), "nomi_agent_run must be on the Remote surface");
|
||||
assert!(remote.contains(&"nomi_agent_result"), "nomi_agent_result must be on the Remote surface");
|
||||
|
||||
// Remote ⊆ Desktop (Desktop is the most permissive surface).
|
||||
for name in &remote {
|
||||
assert!(desktop.contains(name), "Remote tool '{name}' must also be visible on Desktop");
|
||||
}
|
||||
|
||||
// A Destructive tool: listed on Remote (Confirm) and Desktop, hidden on Channel (Deny).
|
||||
assert!(desktop.contains(&"nomi_delete_conversation"));
|
||||
assert!(
|
||||
remote.contains(&"nomi_delete_conversation"),
|
||||
"Destructive tools are Confirm (visible) on the Remote surface"
|
||||
);
|
||||
assert!(
|
||||
!channel.contains(&"nomi_delete_conversation"),
|
||||
"Destructive tools are hard-denied (hidden) on the Channel surface"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! Regression checks for product surfaces that have been intentionally removed.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use common::{build_app, get_with_token, setup_and_login};
|
||||
|
||||
#[tokio::test]
|
||||
async fn removed_console_home_api_is_not_registered() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_token("/api/console/home", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
//! E2E tests for the Requirements Platform HTTP endpoints.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use common::{body_json, build_app, delete_with_token, get_request, get_with_token, json_with_token, setup_and_login};
|
||||
|
||||
#[tokio::test]
|
||||
async fn unauthenticated_list_is_rejected() {
|
||||
let (app, _services) = build_app().await;
|
||||
let resp = app.oneshot(get_request("/api/requirements")).await.unwrap();
|
||||
assert!(
|
||||
resp.status() == StatusCode::UNAUTHORIZED || resp.status() == StatusCode::FORBIDDEN,
|
||||
"expected 401/403, got {}",
|
||||
resp.status()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_list_get_update_delete_happy_path() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// create
|
||||
let body = json!({ "title": "E2E", "content": "x", "tag": "e2e", "order_key": "1" });
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token("POST", "/api/requirements", body, &token, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["status"], "pending");
|
||||
let id = json["data"]["id"].as_i64().unwrap();
|
||||
|
||||
// list (filtered by tag)
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/requirements?tag=e2e", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["total"], 1);
|
||||
assert_eq!(json["data"]["items"][0]["id"], id);
|
||||
|
||||
// get
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token(&format!("/api/requirements/{id}"), &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert_eq!(body_json(resp).await["data"]["id"], id);
|
||||
|
||||
// update → done
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token(
|
||||
"PUT",
|
||||
&format!("/api/requirements/{id}"),
|
||||
json!({ "status": "done", "completion_note": "ok" }),
|
||||
&token,
|
||||
&csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert_eq!(body_json(resp).await["data"]["status"], "done");
|
||||
|
||||
// board
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/requirements/board?tag=e2e", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["done"].as_array().unwrap().len(), 1);
|
||||
|
||||
// tags
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/requirements/tags", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
let e2e = json["data"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|t| t["tag"] == "e2e")
|
||||
.expect("e2e tag summary present");
|
||||
assert_eq!(e2e["done"], 1);
|
||||
|
||||
// delete
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(delete_with_token(&format!("/api/requirements/{id}"), &token, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// get → 404
|
||||
let resp = app
|
||||
.oneshot(get_with_token(&format!("/api/requirements/{id}"), &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_missing_title_is_400() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let resp = app
|
||||
.oneshot(json_with_token(
|
||||
"POST",
|
||||
"/api/requirements",
|
||||
json!({ "title": "", "tag": "e2e" }),
|
||||
&token,
|
||||
&csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body_json(resp).await["code"], "BAD_REQUEST");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_unknown_is_404() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let resp = app
|
||||
.oneshot(get_with_token("/api/requirements/999999", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
assert_eq!(body_json(resp).await["code"], "NOT_FOUND");
|
||||
}
|
||||
|
||||
/// Seed a conversation row so `requirements.conversation_id` FK (set by claim) holds.
|
||||
async fn seed_conversation(services: &nomifun_app::AppServices, conv_id: i64) {
|
||||
sqlx::query(
|
||||
"INSERT INTO conversations (id, user_id, name, type, extra, created_at, updated_at) \
|
||||
VALUES (?, 'system_default_user', 'Dispatch Conv', 'nomi', '{}', 0, 0)",
|
||||
)
|
||||
.bind(conv_id)
|
||||
.execute(services.database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn claim_complete_and_drain() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv = 1;
|
||||
seed_conversation(&services, conv).await;
|
||||
|
||||
// Seed two requirements in tag "disp".
|
||||
for (title, order) in [("A", "1"), ("B", "2")] {
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token(
|
||||
"POST",
|
||||
"/api/requirements",
|
||||
json!({ "title": title, "tag": "disp", "order_key": order }),
|
||||
&token,
|
||||
&csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
}
|
||||
|
||||
// Claim → lowest order (A) goes in_progress.
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token(
|
||||
"POST",
|
||||
"/api/requirements/claim",
|
||||
json!({ "tag": "disp", "conversation_id": conv }),
|
||||
&token,
|
||||
&csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["title"], "A");
|
||||
assert_eq!(json["data"]["status"], "in_progress");
|
||||
let a_id = json["data"]["id"].as_i64().unwrap();
|
||||
|
||||
// Complete A.
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token(
|
||||
"POST",
|
||||
&format!("/api/requirements/{a_id}/complete"),
|
||||
json!({ "completion_note": "ok" }),
|
||||
&token,
|
||||
&csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert_eq!(body_json(resp).await["data"]["status"], "done");
|
||||
|
||||
// Claim again → B.
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token(
|
||||
"POST",
|
||||
"/api/requirements/claim",
|
||||
json!({ "tag": "disp", "conversation_id": conv }),
|
||||
&token,
|
||||
&csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert_eq!(body_json(resp).await["data"]["title"], "B");
|
||||
|
||||
// Claim again → drained (data == null).
|
||||
let resp = app
|
||||
.oneshot(json_with_token(
|
||||
"POST",
|
||||
"/api/requirements/claim",
|
||||
json!({ "tag": "disp", "conversation_id": conv }),
|
||||
&token,
|
||||
&csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert!(body_json(resp).await["data"].is_null(), "tag drained → null");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_autowork_requires_tag_when_enabled() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let conv = "1";
|
||||
seed_conversation(&services, conv.parse().unwrap()).await;
|
||||
|
||||
// enabled without tag → 400.
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token(
|
||||
"POST",
|
||||
"/api/requirements/autowork",
|
||||
json!({ "target_id": conv, "enabled": true }),
|
||||
&token,
|
||||
&csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
// disabled → 200, not running, run_state off.
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token(
|
||||
"POST",
|
||||
"/api/requirements/autowork",
|
||||
json!({ "target_id": conv, "enabled": false }),
|
||||
&token,
|
||||
&csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["enabled"], false);
|
||||
assert_eq!(json["data"]["running"], false);
|
||||
assert_eq!(json["data"]["run_state"], "off");
|
||||
assert_eq!(json["data"]["kind"], "conversation");
|
||||
|
||||
// GET reflects disabled (kind/target_id path form).
|
||||
let resp = app
|
||||
.oneshot(get_with_token(
|
||||
&format!("/api/requirements/autowork/conversation/{conv}"),
|
||||
&token,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert_eq!(body_json(resp).await["data"]["enabled"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_autowork_unknown_terminal_is_not_found() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// Enabling AutoWork on a non-existent terminal → ownership check 404.
|
||||
let resp = app
|
||||
.oneshot(json_with_token(
|
||||
"POST",
|
||||
"/api/requirements/autowork",
|
||||
json!({ "kind": "terminal", "target_id": "term_missing", "enabled": true, "tag": "x" }),
|
||||
&token,
|
||||
&csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn autowork_unknown_kind_is_bad_request() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_token("/api/requirements/autowork/bogus/term_1", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_autowork_rejects_plain_shell() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// Create a plain-shell terminal (no agent backend).
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token(
|
||||
"POST",
|
||||
"/api/terminals",
|
||||
json!({ "cwd": std::env::temp_dir().to_string_lossy(), "command": "$SHELL", "cols": 80, "rows": 24 }),
|
||||
&token,
|
||||
&csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
let term_id = body_json(resp).await["data"]["id"].as_i64().unwrap().to_string();
|
||||
|
||||
// Enabling AutoWork on a plain shell → eligibility check 400.
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token(
|
||||
"POST",
|
||||
"/api/requirements/autowork",
|
||||
json!({ "kind": "terminal", "target_id": term_id, "enabled": true, "tag": "x" }),
|
||||
&token,
|
||||
&csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
// Cleanup: kill/remove the spawned shell.
|
||||
let _ = app
|
||||
.oneshot(json_with_token(
|
||||
"DELETE",
|
||||
&format!("/api/terminals/{term_id}"),
|
||||
json!({}),
|
||||
&token,
|
||||
&csrf,
|
||||
))
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn batch_delete_removes_selected() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// Create three requirements; collect their ids.
|
||||
let mut ids = Vec::new();
|
||||
for (title, order) in [("A", "1"), ("B", "2"), ("C", "3")] {
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token(
|
||||
"POST",
|
||||
"/api/requirements",
|
||||
json!({ "title": title, "tag": "batch", "order_key": order }),
|
||||
&token,
|
||||
&csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
let id = body_json(resp).await["data"]["id"].as_i64().unwrap();
|
||||
ids.push(id);
|
||||
}
|
||||
|
||||
// Batch-delete the first two (plus a non-existent id, which is skipped).
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token(
|
||||
"POST",
|
||||
"/api/requirements/batch-delete",
|
||||
json!({ "ids": [ids[0], ids[1], 999999] }),
|
||||
&token,
|
||||
&csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert_eq!(body_json(resp).await["data"]["deleted"], 2);
|
||||
|
||||
// Only "C" remains.
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/requirements?tag=batch", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["total"], 1);
|
||||
assert_eq!(json["data"]["items"][0]["title"], "C");
|
||||
|
||||
// Empty ids → 400.
|
||||
let resp = app
|
||||
.oneshot(json_with_token(
|
||||
"POST",
|
||||
"/api/requirements/batch-delete",
|
||||
json!({ "ids": [] }),
|
||||
&token,
|
||||
&csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
mod common;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::json;
|
||||
use tower::ServiceExt;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
use common::{body_json, build_app_with_noop_opener, json_with_token, setup_and_login};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: build multipart/form-data body
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct MultipartBuilder {
|
||||
boundary: String,
|
||||
parts: Vec<u8>,
|
||||
}
|
||||
|
||||
impl MultipartBuilder {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
boundary: "----TestBoundary7MA4YWxkTrZu0gW".to_owned(),
|
||||
parts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_text(mut self, name: &str, value: &str) -> Self {
|
||||
self.parts
|
||||
.extend_from_slice(format!("--{}\r\n", self.boundary).as_bytes());
|
||||
self.parts
|
||||
.extend_from_slice(format!("Content-Disposition: form-data; name=\"{name}\"\r\n\r\n").as_bytes());
|
||||
self.parts.extend_from_slice(value.as_bytes());
|
||||
self.parts.extend_from_slice(b"\r\n");
|
||||
self
|
||||
}
|
||||
|
||||
fn add_file(mut self, name: &str, filename: &str, mime: &str, data: &[u8]) -> Self {
|
||||
self.parts
|
||||
.extend_from_slice(format!("--{}\r\n", self.boundary).as_bytes());
|
||||
self.parts.extend_from_slice(
|
||||
format!("Content-Disposition: form-data; name=\"{name}\"; filename=\"{filename}\"\r\n").as_bytes(),
|
||||
);
|
||||
self.parts
|
||||
.extend_from_slice(format!("Content-Type: {mime}\r\n\r\n").as_bytes());
|
||||
self.parts.extend_from_slice(data);
|
||||
self.parts.extend_from_slice(b"\r\n");
|
||||
self
|
||||
}
|
||||
|
||||
fn build(mut self) -> (String, Vec<u8>) {
|
||||
self.parts
|
||||
.extend_from_slice(format!("--{}--\r\n", self.boundary).as_bytes());
|
||||
let content_type = format!("multipart/form-data; boundary={}", self.boundary);
|
||||
(content_type, self.parts)
|
||||
}
|
||||
}
|
||||
|
||||
fn multipart_request(uri: &str, content_type: &str, body: Vec<u8>, token: &str, csrf: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(uri)
|
||||
.header("content-type", content_type)
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("x-csrf-token", csrf)
|
||||
.header("cookie", format!("nomifun-csrf-token={csrf}"))
|
||||
.body(Body::from(body))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn set_stt_config(app: &mut axum::Router, token: &str, csrf: &str, config: serde_json::Value) {
|
||||
let req = json_with_token(
|
||||
"PUT",
|
||||
"/api/settings/client",
|
||||
json!({ "speechToText": config }),
|
||||
token,
|
||||
csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// A. Shell Operations
|
||||
// ===========================================================================
|
||||
|
||||
// SH-2: open-file — file not found
|
||||
#[tokio::test]
|
||||
async fn sh2_open_file_not_found() {
|
||||
let (mut app, services) = build_app_with_noop_opener().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/shell/open-file",
|
||||
json!({ "file_path": "/nonexistent/file.txt" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], false);
|
||||
}
|
||||
|
||||
// SH-4: show-item-in-folder — path not found
|
||||
#[tokio::test]
|
||||
async fn sh4_show_item_in_folder_not_found() {
|
||||
let (mut app, services) = build_app_with_noop_opener().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/shell/show-item-in-folder",
|
||||
json!({ "file_path": "/nonexistent/path" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// SH-6: open-external — command injection attempt
|
||||
#[tokio::test]
|
||||
async fn sh6_open_external_command_injection() {
|
||||
let (mut app, services) = build_app_with_noop_opener().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/shell/open-external",
|
||||
json!({ "url": "; rm -rf /" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], false);
|
||||
}
|
||||
|
||||
// SH-7: open-external — disallowed scheme
|
||||
#[tokio::test]
|
||||
async fn sh7_open_external_file_scheme() {
|
||||
let (mut app, services) = build_app_with_noop_opener().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/shell/open-external",
|
||||
json!({ "url": "file:///etc/passwd" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// SH-8: check-tool-installed — terminal always true
|
||||
#[tokio::test]
|
||||
async fn sh8_check_tool_terminal() {
|
||||
let (mut app, services) = build_app_with_noop_opener().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/shell/check-tool-installed",
|
||||
json!({ "tool": "terminal" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
assert_eq!(json["data"]["installed"], true);
|
||||
}
|
||||
|
||||
// SH-9: check-tool-installed — explorer always true
|
||||
#[tokio::test]
|
||||
async fn sh9_check_tool_explorer() {
|
||||
let (mut app, services) = build_app_with_noop_opener().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/shell/check-tool-installed",
|
||||
json!({ "tool": "explorer" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
assert_eq!(json["data"]["installed"], true);
|
||||
}
|
||||
|
||||
// SH-10: check-tool-installed — vscode (result depends on environment)
|
||||
#[tokio::test]
|
||||
async fn sh10_check_tool_vscode() {
|
||||
let (mut app, services) = build_app_with_noop_opener().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/shell/check-tool-installed",
|
||||
json!({ "tool": "vscode" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
assert!(json["data"]["installed"].is_boolean());
|
||||
}
|
||||
|
||||
// SH-12: open-folder-with — directory not found
|
||||
#[tokio::test]
|
||||
async fn sh12_open_folder_with_nonexistent() {
|
||||
let (mut app, services) = build_app_with_noop_opener().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/shell/open-folder-with",
|
||||
json!({ "folder_path": "/nonexistent/dir", "tool": "explorer" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// SH-13: open-file — missing filePath
|
||||
#[tokio::test]
|
||||
async fn sh13_open_file_missing_field() {
|
||||
let (mut app, services) = build_app_with_noop_opener().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = json_with_token("POST", "/api/shell/open-file", json!({}), &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// SH-14: open-external — empty URL
|
||||
#[tokio::test]
|
||||
async fn sh14_open_external_empty_url() {
|
||||
let (mut app, services) = build_app_with_noop_opener().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = json_with_token("POST", "/api/shell/open-external", json!({ "url": "" }), &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// B. Speech-to-Text (STT)
|
||||
// ===========================================================================
|
||||
|
||||
// ST-3: STT not enabled
|
||||
#[tokio::test]
|
||||
async fn st3_stt_disabled() {
|
||||
let (mut app, services) = build_app_with_noop_opener().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
set_stt_config(
|
||||
&mut app,
|
||||
&token,
|
||||
&csrf,
|
||||
json!({ "enabled": false, "provider": "openai" }),
|
||||
)
|
||||
.await;
|
||||
|
||||
let (content_type, body) = MultipartBuilder::new()
|
||||
.add_file("file", "test.wav", "audio/wav", b"fake audio data")
|
||||
.add_text("fileName", "test.wav")
|
||||
.add_text("mimeType", "audio/wav")
|
||||
.build();
|
||||
|
||||
let req = multipart_request("/api/stt", &content_type, body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["code"], "STT_DISABLED");
|
||||
}
|
||||
|
||||
// ST-4: STT config not set (treated as disabled)
|
||||
#[tokio::test]
|
||||
async fn st4_stt_config_not_set() {
|
||||
let (mut app, services) = build_app_with_noop_opener().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let (content_type, body) = MultipartBuilder::new()
|
||||
.add_file("file", "test.wav", "audio/wav", b"fake audio data")
|
||||
.add_text("fileName", "test.wav")
|
||||
.add_text("mimeType", "audio/wav")
|
||||
.build();
|
||||
|
||||
let req = multipart_request("/api/stt", &content_type, body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["code"], "STT_DISABLED");
|
||||
}
|
||||
|
||||
// ST-5: OpenAI not configured (missing API key)
|
||||
#[tokio::test]
|
||||
async fn st5_openai_not_configured() {
|
||||
let (mut app, services) = build_app_with_noop_opener().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
set_stt_config(
|
||||
&mut app,
|
||||
&token,
|
||||
&csrf,
|
||||
json!({
|
||||
"enabled": true,
|
||||
"provider": "openai",
|
||||
"openai": { "api_key": "", "model": "whisper-1" }
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let (content_type, body) = MultipartBuilder::new()
|
||||
.add_file("file", "test.wav", "audio/wav", b"fake audio data")
|
||||
.add_text("fileName", "test.wav")
|
||||
.add_text("mimeType", "audio/wav")
|
||||
.build();
|
||||
|
||||
let req = multipart_request("/api/stt", &content_type, body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["code"], "STT_OPENAI_NOT_CONFIGURED");
|
||||
}
|
||||
|
||||
// ST-6: Deepgram not configured (missing API key)
|
||||
#[tokio::test]
|
||||
async fn st6_deepgram_not_configured() {
|
||||
let (mut app, services) = build_app_with_noop_opener().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
set_stt_config(
|
||||
&mut app,
|
||||
&token,
|
||||
&csrf,
|
||||
json!({
|
||||
"enabled": true,
|
||||
"provider": "deepgram",
|
||||
"deepgram": { "api_key": "", "model": "nova-2" }
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let (content_type, body) = MultipartBuilder::new()
|
||||
.add_file("file", "test.wav", "audio/wav", b"fake audio data")
|
||||
.add_text("fileName", "test.wav")
|
||||
.add_text("mimeType", "audio/wav")
|
||||
.build();
|
||||
|
||||
let req = multipart_request("/api/stt", &content_type, body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["code"], "STT_DEEPGRAM_NOT_CONFIGURED");
|
||||
}
|
||||
|
||||
// ST-7: STT third-party API failure (fake API key → 401)
|
||||
#[tokio::test]
|
||||
async fn st7_stt_api_failure() {
|
||||
let mock_server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/audio/transcriptions"))
|
||||
.respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized"))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let (mut app, services) = build_app_with_noop_opener().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
set_stt_config(
|
||||
&mut app,
|
||||
&token,
|
||||
&csrf,
|
||||
json!({
|
||||
"enabled": true,
|
||||
"provider": "openai",
|
||||
"openai": {
|
||||
"api_key": "sk-fake-key",
|
||||
"base_url": mock_server.uri(),
|
||||
"model": "whisper-1"
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let (content_type, body) = MultipartBuilder::new()
|
||||
.add_file("file", "test.wav", "audio/wav", b"fake audio data")
|
||||
.add_text("fileName", "test.wav")
|
||||
.add_text("mimeType", "audio/wav")
|
||||
.build();
|
||||
|
||||
let req = multipart_request("/api/stt", &content_type, body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["code"], "STT_REQUEST_FAILED");
|
||||
}
|
||||
|
||||
// ST-8: multipart missing fileName
|
||||
#[tokio::test]
|
||||
async fn st8_multipart_missing_filename() {
|
||||
let (mut app, services) = build_app_with_noop_opener().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
set_stt_config(
|
||||
&mut app,
|
||||
&token,
|
||||
&csrf,
|
||||
json!({ "enabled": true, "provider": "openai", "openai": { "api_key": "sk-test", "model": "whisper-1" } }),
|
||||
)
|
||||
.await;
|
||||
|
||||
let (content_type, body) = MultipartBuilder::new()
|
||||
.add_file("file", "test.wav", "audio/wav", b"fake audio data")
|
||||
.add_text("mimeType", "audio/wav")
|
||||
.build();
|
||||
|
||||
let req = multipart_request("/api/stt", &content_type, body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], false);
|
||||
}
|
||||
|
||||
// ST-9: multipart missing file
|
||||
#[tokio::test]
|
||||
async fn st9_multipart_missing_file() {
|
||||
let (mut app, services) = build_app_with_noop_opener().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
set_stt_config(
|
||||
&mut app,
|
||||
&token,
|
||||
&csrf,
|
||||
json!({ "enabled": true, "provider": "openai", "openai": { "api_key": "sk-test", "model": "whisper-1" } }),
|
||||
)
|
||||
.await;
|
||||
|
||||
let (content_type, body) = MultipartBuilder::new()
|
||||
.add_text("fileName", "test.wav")
|
||||
.add_text("mimeType", "audio/wav")
|
||||
.build();
|
||||
|
||||
let req = multipart_request("/api/stt", &content_type, body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], false);
|
||||
}
|
||||
|
||||
// ST-1: OpenAI transcription success (mocked)
|
||||
#[tokio::test]
|
||||
async fn st1_openai_transcription_success() {
|
||||
let mock_server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/audio/transcriptions"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "text": "hello world" })))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let (mut app, services) = build_app_with_noop_opener().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
set_stt_config(
|
||||
&mut app,
|
||||
&token,
|
||||
&csrf,
|
||||
json!({
|
||||
"enabled": true,
|
||||
"provider": "openai",
|
||||
"openai": {
|
||||
"api_key": "sk-test-key",
|
||||
"base_url": mock_server.uri(),
|
||||
"model": "whisper-1"
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let (content_type, body) = MultipartBuilder::new()
|
||||
.add_file("file", "test.wav", "audio/wav", b"fake audio data")
|
||||
.add_text("fileName", "test.wav")
|
||||
.add_text("mimeType", "audio/wav")
|
||||
.build();
|
||||
|
||||
let req = multipart_request("/api/stt", &content_type, body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
assert_eq!(json["data"]["text"], "hello world");
|
||||
assert_eq!(json["data"]["model"], "whisper-1");
|
||||
assert_eq!(json["data"]["provider"], "openai");
|
||||
}
|
||||
|
||||
// ST-2: Deepgram transcription success (mocked)
|
||||
#[tokio::test]
|
||||
async fn st2_deepgram_transcription_success() {
|
||||
let mock_server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"metadata": {
|
||||
"model_info": {
|
||||
"key": { "name": "nova-2-general" }
|
||||
}
|
||||
},
|
||||
"results": {
|
||||
"channels": [{
|
||||
"alternatives": [{
|
||||
"transcript": "hello from deepgram"
|
||||
}]
|
||||
}]
|
||||
}
|
||||
})))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let (mut app, services) = build_app_with_noop_opener().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
set_stt_config(
|
||||
&mut app,
|
||||
&token,
|
||||
&csrf,
|
||||
json!({
|
||||
"enabled": true,
|
||||
"provider": "deepgram",
|
||||
"deepgram": {
|
||||
"api_key": "dg-test-key",
|
||||
"base_url": mock_server.uri(),
|
||||
"model": "nova-2"
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let (content_type, body) = MultipartBuilder::new()
|
||||
.add_file("file", "test.wav", "audio/wav", b"fake audio data")
|
||||
.add_text("fileName", "test.wav")
|
||||
.add_text("mimeType", "audio/wav")
|
||||
.build();
|
||||
|
||||
let req = multipart_request("/api/stt", &content_type, body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
assert_eq!(json["data"]["text"], "hello from deepgram");
|
||||
assert_eq!(json["data"]["provider"], "deepgram");
|
||||
}
|
||||
|
||||
// ST-10: languageHint passed through
|
||||
#[tokio::test]
|
||||
async fn st10_language_hint_passed() {
|
||||
let mock_server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/audio/transcriptions"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "text": "你好世界" })))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let (mut app, services) = build_app_with_noop_opener().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
set_stt_config(
|
||||
&mut app,
|
||||
&token,
|
||||
&csrf,
|
||||
json!({
|
||||
"enabled": true,
|
||||
"provider": "openai",
|
||||
"openai": {
|
||||
"api_key": "sk-test-key",
|
||||
"base_url": mock_server.uri(),
|
||||
"model": "whisper-1"
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let (content_type, body) = MultipartBuilder::new()
|
||||
.add_file("file", "test.wav", "audio/wav", b"fake audio data")
|
||||
.add_text("fileName", "test.wav")
|
||||
.add_text("mimeType", "audio/wav")
|
||||
.add_text("languageHint", "zh")
|
||||
.build();
|
||||
|
||||
let req = multipart_request("/api/stt", &content_type, body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
assert_eq!(json["data"]["text"], "你好世界");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// C. Authentication
|
||||
// ===========================================================================
|
||||
|
||||
// AU-1: unauthenticated shell request rejected
|
||||
#[tokio::test]
|
||||
async fn au1_shell_unauthenticated() {
|
||||
let (app, _services) = build_app_with_noop_opener().await;
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/shell/open-file")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"file_path":"/tmp/test.txt"}"#))
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// AU-2: unauthenticated STT request rejected
|
||||
#[tokio::test]
|
||||
async fn au2_stt_unauthenticated() {
|
||||
let (app, _services) = build_app_with_noop_opener().await;
|
||||
|
||||
let (content_type, body) = MultipartBuilder::new()
|
||||
.add_file("file", "test.wav", "audio/wav", b"fake audio")
|
||||
.add_text("fileName", "test.wav")
|
||||
.add_text("mimeType", "audio/wav")
|
||||
.build();
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/stt")
|
||||
.header("content-type", content_type)
|
||||
.body(Body::from(body))
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// M-145: mailto scheme URL positive test
|
||||
#[tokio::test]
|
||||
async fn sh_open_external_mailto_scheme() {
|
||||
let (mut app, services) = build_app_with_noop_opener().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/shell/open-external",
|
||||
json!({ "url": "mailto:user@example.com" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
// M-147: multipart missing mimeType field
|
||||
#[tokio::test]
|
||||
async fn st_multipart_missing_mimetype() {
|
||||
let (mut app, services) = build_app_with_noop_opener().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
set_stt_config(
|
||||
&mut app,
|
||||
&token,
|
||||
&csrf,
|
||||
json!({ "enabled": true, "provider": "openai", "openai": { "api_key": "sk-test", "model": "whisper-1" } }),
|
||||
)
|
||||
.await;
|
||||
|
||||
let (content_type, body) = MultipartBuilder::new()
|
||||
.add_file("file", "test.wav", "audio/wav", b"fake audio data")
|
||||
.add_text("fileName", "test.wav")
|
||||
.build();
|
||||
|
||||
let req = multipart_request("/api/stt", &content_type, body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], false);
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
//! HTTP integration tests for the built-in skills migration surface:
|
||||
//! `/api/skills/builtin-auto`, `/api/skills/builtin-skill`, `/api/skills`,
|
||||
//! and the symlink-contract `/api/skills/materialize-for-agent` (POST).
|
||||
//!
|
||||
//! Covers the spec's §9.2 scenarios end-to-end through
|
||||
//! `nomifun_app::create_router_with_states` against an in-memory DB.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use nomifun_app::{ModuleStates, build_module_states, create_router_with_states};
|
||||
use nomifun_db::init_database_memory;
|
||||
use nomifun_extension::{ExternalPathsManager, SkillPaths, SkillRouterState};
|
||||
use serde_json::{Value, json};
|
||||
use tempfile::TempDir;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use common::{body_json, get_with_token, json_with_token, setup_and_login};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture — build router with embedded-corpus paths rooted at a temp dir
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct Fixture {
|
||||
app: axum::Router,
|
||||
token: String,
|
||||
csrf: String,
|
||||
data_dir: std::path::PathBuf,
|
||||
_tmp: TempDir,
|
||||
}
|
||||
|
||||
/// Build an app whose skill state points at a freshly materialized
|
||||
/// builtin-skills tree rooted at a temp `data_dir`. `write_skill` can
|
||||
/// still seed user skills under `{data_dir}/skills/`.
|
||||
async fn fixture_embedded() -> Fixture {
|
||||
// Ensure no env override interferes.
|
||||
// SAFETY: tests in this file may mutate this env var across async
|
||||
// tasks on the same process. Rust 2024 marks `remove_var` as unsafe
|
||||
// for exactly that reason. The var is only read at router-state
|
||||
// construction time, and each test calls `fixture_embedded` once at
|
||||
// the top, so the mutation is race-free in practice.
|
||||
unsafe {
|
||||
std::env::remove_var("NOMIFUN_BUILTIN_SKILLS_PATH");
|
||||
}
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let data_dir = tmp.path().to_path_buf();
|
||||
|
||||
// Materialize the embedded corpus onto the temp data dir so the
|
||||
// per-test router can read it just like production would.
|
||||
nomifun_extension::materialize_if_needed(&data_dir, nomifun_extension::builtin_skills_corpus(), "test-fixture")
|
||||
.await
|
||||
.expect("failed to materialize embedded builtin skills for test fixture");
|
||||
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let services = nomifun_app::AppServices::from_config(db, &nomifun_app::AppConfig::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let (mut states, _): (ModuleStates, _) = build_module_states(&services).await;
|
||||
|
||||
// Replace the skill state with a deterministic one rooted at tmp.
|
||||
// `build_module_states` builds a state pointing at `~/.nomifun/`,
|
||||
// which is fine for production but unsuitable here.
|
||||
let skill_paths = SkillPaths {
|
||||
data_dir: data_dir.clone(),
|
||||
user_skills_dir: data_dir.join("skills"),
|
||||
cron_skills_dir: data_dir.join("cron").join("skills"),
|
||||
builtin_skills_dir: data_dir.join("builtin-skills"),
|
||||
builtin_rules_dir: data_dir.join("builtin-rules"),
|
||||
assistant_rules_dir: data_dir.join("assistant-rules"),
|
||||
assistant_skills_dir: data_dir.join("assistant-skills"),
|
||||
};
|
||||
let ext_paths_mgr = Arc::new(ExternalPathsManager::with_file(data_dir.join("paths.json")).await);
|
||||
states.skill = SkillRouterState {
|
||||
skill_paths,
|
||||
external_paths_manager: ext_paths_mgr,
|
||||
assistant_dispatcher: states.skill.assistant_dispatcher.clone(),
|
||||
skill_tag_repo: std::sync::Arc::new(nomifun_db::SqliteSkillTagRepository::new(
|
||||
services.database.pool().clone(),
|
||||
)),
|
||||
builtin_skill_tags: std::sync::Arc::new(std::collections::HashMap::new()),
|
||||
};
|
||||
|
||||
let mut app = create_router_with_states(&services, states);
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "builtin-e2e", "StrongP@ss1").await;
|
||||
|
||||
Fixture {
|
||||
app,
|
||||
token,
|
||||
csrf,
|
||||
data_dir,
|
||||
_tmp: tmp,
|
||||
}
|
||||
}
|
||||
|
||||
fn write_user_skill(dir: &std::path::Path, name: &str, desc: &str) {
|
||||
let skill_dir = dir.join("skills").join(name);
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
std::fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
format!("---\nname: {name}\ndescription: {desc}\n---\nBody for {name}."),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// GET /api/skills/builtin-auto — embedded corpus
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn builtin_auto_lists_entries_from_embedded_corpus() {
|
||||
let fx = fixture_embedded().await;
|
||||
|
||||
let resp = fx
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/skills/builtin-auto", &fx.token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
let arr = json["data"].as_array().unwrap();
|
||||
assert!(arr.len() >= 4, "expected ≥4 auto-inject entries, got {}", arr.len());
|
||||
for item in arr {
|
||||
assert!(item["name"].is_string());
|
||||
assert!(item["description"].is_string());
|
||||
let loc = item["location"].as_str().unwrap();
|
||||
assert!(loc.starts_with("auto-inject/"), "location={loc}");
|
||||
assert!(loc.ends_with("/SKILL.md"));
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// POST /api/skills/builtin-skill
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn builtin_skill_read_auto_inject_returns_frontmatter_content() {
|
||||
let fx = fixture_embedded().await;
|
||||
|
||||
let resp = fx
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(json_with_token(
|
||||
"POST",
|
||||
"/api/skills/builtin-skill",
|
||||
json!({"file_name": "auto-inject/cron/SKILL.md"}),
|
||||
&fx.token,
|
||||
&fx.csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
let content = json["data"].as_str().unwrap();
|
||||
assert!(content.trim_start().starts_with("---"), "content={content}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn builtin_skill_read_opt_in_returns_frontmatter_content() {
|
||||
let fx = fixture_embedded().await;
|
||||
|
||||
// mermaid is a well-known opt-in skill in the corpus.
|
||||
let resp = fx
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(json_with_token(
|
||||
"POST",
|
||||
"/api/skills/builtin-skill",
|
||||
json!({"file_name": "mermaid/SKILL.md"}),
|
||||
&fx.token,
|
||||
&fx.csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
let content = json["data"].as_str().unwrap();
|
||||
assert!(!content.is_empty(), "mermaid SKILL.md is empty");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn builtin_skill_missing_file_returns_empty_string() {
|
||||
let fx = fixture_embedded().await;
|
||||
|
||||
let resp = fx
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(json_with_token(
|
||||
"POST",
|
||||
"/api/skills/builtin-skill",
|
||||
json!({"file_name": "unknown/SKILL.md"}),
|
||||
&fx.token,
|
||||
&fx.csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"], "");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn builtin_skill_rejects_traversal() {
|
||||
let fx = fixture_embedded().await;
|
||||
|
||||
for bad in ["../etc/passwd", "/etc/passwd", "auto-inject/../../escape", ""] {
|
||||
let resp = fx
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(json_with_token(
|
||||
"POST",
|
||||
"/api/skills/builtin-skill",
|
||||
json!({"file_name": bad}),
|
||||
&fx.token,
|
||||
&fx.csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::BAD_REQUEST,
|
||||
"file_name={bad:?} should be rejected",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// GET /api/skills — merged list with relative_location for builtin
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_skills_builtin_entries_carry_relative_location() {
|
||||
let fx = fixture_embedded().await;
|
||||
|
||||
// Seed one user skill so the merge is non-trivial.
|
||||
write_user_skill(&fx.data_dir, "my-custom", "Custom skill for test");
|
||||
|
||||
let resp = fx
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/skills", &fx.token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
let arr = json["data"].as_array().unwrap();
|
||||
|
||||
let mut saw_builtin = false;
|
||||
let mut saw_custom = false;
|
||||
for item in arr {
|
||||
match item["source"].as_str().unwrap() {
|
||||
"builtin" => {
|
||||
saw_builtin = true;
|
||||
let rel = item["relative_location"].as_str().unwrap();
|
||||
assert!(rel.ends_with("/SKILL.md"));
|
||||
let loc = item["location"].as_str().unwrap();
|
||||
assert!(
|
||||
loc.contains("builtin-skills"),
|
||||
"builtin location should live under builtin-skills dir: {loc}"
|
||||
);
|
||||
// The builtin-skills tree is materialized at startup, so
|
||||
// SKILL.md must already exist on disk.
|
||||
assert!(
|
||||
std::path::Path::new(loc).exists(),
|
||||
"builtin skill file missing on disk: {loc}"
|
||||
);
|
||||
}
|
||||
"custom" => {
|
||||
saw_custom = true;
|
||||
assert!(item.get("relative_location").is_none());
|
||||
assert!(item.get("relative_location").is_none());
|
||||
assert_eq!(item["name"], "my-custom");
|
||||
}
|
||||
other => panic!("unexpected source: {other}"),
|
||||
}
|
||||
}
|
||||
assert!(saw_builtin, "expected at least one builtin entry");
|
||||
assert!(saw_custom, "expected the seeded custom entry");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// POST /api/skills/materialize-for-agent
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn materialize_for_agent_returns_source_path_for_auto_inject_skill() {
|
||||
// Post-snapshot contract: `materialize-for-agent` resolves each
|
||||
// requested name to its on-disk source directory without copying.
|
||||
// The frontend symlinks `source_path` into the CLI's native skills
|
||||
// dir. `cron` lives under `auto-inject/cron/` in the builtin tree.
|
||||
let fx = fixture_embedded().await;
|
||||
|
||||
let resp = fx
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(json_with_token(
|
||||
"POST",
|
||||
"/api/skills/materialize-for-agent",
|
||||
json!({
|
||||
"conversation_id": 1,
|
||||
"skills": ["cron"],
|
||||
}),
|
||||
&fx.token,
|
||||
&fx.csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json: Value = body_json(resp).await;
|
||||
let skills = json["data"]["skills"].as_array().unwrap();
|
||||
assert_eq!(skills.len(), 1);
|
||||
assert_eq!(skills[0]["name"], "cron");
|
||||
let source_path = skills[0]["source_path"].as_str().unwrap();
|
||||
let path = std::path::Path::new(source_path);
|
||||
assert!(path.is_absolute(), "source_path must be absolute: {source_path}");
|
||||
assert!(path.is_dir(), "source_path must exist: {source_path}");
|
||||
assert!(
|
||||
path.join("SKILL.md").exists(),
|
||||
"source_path must contain SKILL.md at {source_path}",
|
||||
);
|
||||
// It must live under the builtin tree, not under a
|
||||
// per-conversation copy dir.
|
||||
assert!(
|
||||
source_path.contains("builtin-skills"),
|
||||
"expected auto-inject source under builtin-skills, got {source_path}",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn materialize_for_agent_returns_source_path_for_opt_in_skill() {
|
||||
let fx = fixture_embedded().await;
|
||||
|
||||
let resp = fx
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(json_with_token(
|
||||
"POST",
|
||||
"/api/skills/materialize-for-agent",
|
||||
json!({
|
||||
"conversation_id": 1,
|
||||
"enabled_skills": ["mermaid"],
|
||||
}),
|
||||
&fx.token,
|
||||
&fx.csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json: Value = body_json(resp).await;
|
||||
let skills = json["data"]["skills"].as_array().unwrap();
|
||||
assert_eq!(skills.len(), 1);
|
||||
assert_eq!(skills[0]["name"], "mermaid");
|
||||
let source_path = skills[0]["source_path"].as_str().unwrap();
|
||||
assert!(
|
||||
std::path::Path::new(source_path).join("SKILL.md").exists(),
|
||||
"mermaid source_path must exist: {source_path}",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn materialize_for_agent_silently_skips_unknown_skill() {
|
||||
let fx = fixture_embedded().await;
|
||||
|
||||
let resp = fx
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(json_with_token(
|
||||
"POST",
|
||||
"/api/skills/materialize-for-agent",
|
||||
json!({
|
||||
"conversation_id": 1,
|
||||
"enabled_skills": ["this-does-not-exist"],
|
||||
}),
|
||||
&fx.token,
|
||||
&fx.csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json: Value = body_json(resp).await;
|
||||
let skills = json["data"]["skills"].as_array().unwrap();
|
||||
// Unknown skill is silently dropped.
|
||||
assert!(skills.is_empty(), "unknown skills must be silently omitted");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn materialize_for_agent_does_not_touch_data_dir() {
|
||||
// Symlink-contract guardrail: the backend no longer writes anywhere
|
||||
// under {data_dir}/agent-skills/ or {data_dir}/conversations/ for
|
||||
// materialize-for-agent — it only reads the source tree.
|
||||
let fx = fixture_embedded().await;
|
||||
|
||||
fx.app
|
||||
.clone()
|
||||
.oneshot(json_with_token(
|
||||
"POST",
|
||||
"/api/skills/materialize-for-agent",
|
||||
json!({"conversation_id": 1, "enabled_skills": ["cron"]}),
|
||||
&fx.token,
|
||||
&fx.csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!fx.data_dir.join("agent-skills").exists());
|
||||
assert!(!fx.data_dir.join("conversations").join("1").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn materialize_for_agent_returns_sorted_list() {
|
||||
let fx = fixture_embedded().await;
|
||||
|
||||
let resp = fx
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(json_with_token(
|
||||
"POST",
|
||||
"/api/skills/materialize-for-agent",
|
||||
json!({
|
||||
"conversation_id": 1,
|
||||
"skills": ["mermaid", "cron"],
|
||||
}),
|
||||
&fx.token,
|
||||
&fx.csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json: Value = body_json(resp).await;
|
||||
let skills = json["data"]["skills"].as_array().unwrap();
|
||||
assert_eq!(skills.len(), 2);
|
||||
assert_eq!(skills[0]["name"], "cron");
|
||||
assert_eq!(skills[1]["name"], "mermaid");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn materialize_for_agent_rejects_empty_conversation_id() {
|
||||
let fx = fixture_embedded().await;
|
||||
|
||||
let resp = fx
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(json_with_token(
|
||||
"POST",
|
||||
"/api/skills/materialize-for-agent",
|
||||
json!({"conversation_id": "", "enabled_skills": []}),
|
||||
&fx.token,
|
||||
&fx.csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn materialize_for_agent_rejects_traversal_in_conversation_id() {
|
||||
let fx = fixture_embedded().await;
|
||||
|
||||
let resp = fx
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(json_with_token(
|
||||
"POST",
|
||||
"/api/skills/materialize-for-agent",
|
||||
json!({"conversation_id": "../evil", "enabled_skills": []}),
|
||||
&fx.token,
|
||||
&fx.csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// DELETE /api/skills/materialize-for-agent/:conversation_id removed — the
|
||||
// symlink contract has nothing to clean up on the backend side.
|
||||
// ===========================================================================
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Smoke test: starting the app twice with the same binary version
|
||||
//! should be a no-op on the second run (version gate skips rewrite).
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn second_start_with_same_version_is_noop() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let data_dir = tmp.path();
|
||||
|
||||
let first =
|
||||
nomifun_extension::materialize_if_needed(data_dir, nomifun_extension::builtin_skills_corpus(), "test-1.0.0")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(first, "first call should materialize");
|
||||
|
||||
let second =
|
||||
nomifun_extension::materialize_if_needed(data_dir, nomifun_extension::builtin_skills_corpus(), "test-1.0.0")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!second, "second call with same version should skip");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn version_bump_triggers_rewrite() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let data_dir = tmp.path();
|
||||
|
||||
let first =
|
||||
nomifun_extension::materialize_if_needed(data_dir, nomifun_extension::builtin_skills_corpus(), "test-1.0.0")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(first);
|
||||
|
||||
let second =
|
||||
nomifun_extension::materialize_if_needed(data_dir, nomifun_extension::builtin_skills_corpus(), "test-2.0.0")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(second, "version change should trigger a fresh materialize");
|
||||
|
||||
let version = std::fs::read_to_string(data_dir.join("builtin-skills").join(".version")).unwrap();
|
||||
assert_eq!(version, "test-2.0.0");
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Auth protection tests — all system endpoints return 403 without auth.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use common::{build_app, get_request};
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_required_get_settings() {
|
||||
let (app, _) = build_app().await;
|
||||
let resp = app.oneshot(get_request("/api/settings")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_required_patch_settings() {
|
||||
let (app, _) = build_app().await;
|
||||
let req = Request::builder()
|
||||
.method("PATCH")
|
||||
.uri("/api/settings")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"language":"en-US"}"#))
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_required_get_client_prefs() {
|
||||
let (app, _) = build_app().await;
|
||||
let resp = app.oneshot(get_request("/api/settings/client")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_required_put_client_prefs() {
|
||||
let (app, _) = build_app().await;
|
||||
let req = Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/settings/client")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"key":"value"}"#))
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_required_get_providers() {
|
||||
let (app, _) = build_app().await;
|
||||
let resp = app.oneshot(get_request("/api/providers")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_required_post_providers() {
|
||||
let (app, _) = build_app().await;
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/providers")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"platform":"openai","name":"Test","base_url":"https://api.openai.com","api_key":"sk-test"}"#,
|
||||
))
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_required_delete_provider() {
|
||||
let (app, _) = build_app().await;
|
||||
let req = Request::builder()
|
||||
.method("DELETE")
|
||||
.uri("/api/providers/some-id")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_required_system_info() {
|
||||
let (app, _) = build_app().await;
|
||||
let resp = app.oneshot(get_request("/api/system/info")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_required_check_update() {
|
||||
let (app, _) = build_app().await;
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/system/check-update")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{}"#))
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_required_detect_protocol() {
|
||||
let (app, _) = build_app().await;
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/providers/detect-protocol")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"base_url":"https://api.example.com","api_key":"sk-test"}"#,
|
||||
))
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_required_fetch_models() {
|
||||
let (app, _) = build_app().await;
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/providers/some-id/models")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{}"#))
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
//! Provider CRUD, model fetch, and protocol detection tests with auth.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use tower::ServiceExt;
|
||||
use wiremock::matchers::{header as match_header, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
use common::{body_json, build_app, delete_with_token, get_with_token, json_with_token, setup_and_login};
|
||||
|
||||
// ===========================================================================
|
||||
// Provider CRUD
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_full_crud_with_auth() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// 1. List — empty
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/providers", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"], json!([]));
|
||||
|
||||
// 2. Create
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/providers",
|
||||
json!({
|
||||
"platform": "anthropic",
|
||||
"name": "Anthropic",
|
||||
"base_url": "https://api.anthropic.com",
|
||||
"api_key": "sk-ant-api03-test1234"
|
||||
}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
let json = body_json(resp).await;
|
||||
let id = json["data"]["id"].as_str().unwrap().to_string();
|
||||
assert_eq!(json["data"]["platform"], "anthropic");
|
||||
assert_eq!(json["data"]["name"], "Anthropic");
|
||||
let api_key = json["data"]["api_key"].as_str().unwrap();
|
||||
assert_eq!(
|
||||
api_key, "sk-ant-api03-test1234",
|
||||
"API key should be plaintext on the wire (pre-launch)"
|
||||
);
|
||||
|
||||
// 3. List — should contain one
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/providers", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"].as_array().unwrap().len(), 1);
|
||||
|
||||
// 4. Update
|
||||
let req = json_with_token(
|
||||
"PUT",
|
||||
&format!("/api/providers/{id}"),
|
||||
json!({"name": "Updated Name", "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"]["name"], "Updated Name");
|
||||
assert!(!json["data"]["enabled"].as_bool().unwrap());
|
||||
|
||||
// 5. Delete
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(delete_with_token(&format!("/api/providers/{id}"), &token, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// 6. Verify deleted
|
||||
let resp = app.oneshot(get_with_token("/api/providers", &token)).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"], json!([]));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_create_validation_with_auth() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// Missing platform
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/providers",
|
||||
json!({"name": "Test", "base_url": "https://api.example.com", "api_key": "sk-test"}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
// Invalid URL
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/providers",
|
||||
json!({"platform": "openai", "name": "Test", "base_url": "not-a-url", "api_key": "sk-test"}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_update_nonexistent_with_auth() {
|
||||
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/providers/nonexistent", json!({"name": "X"}), &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_delete_nonexistent_with_auth() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(delete_with_token("/api/providers/nonexistent", &token, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Model fetch
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_fetch_openai_with_auth() {
|
||||
let mock_server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/models"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"data": [{"id": "gpt-4o"}, {"id": "gpt-4o-mini"}]
|
||||
})))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
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(
|
||||
"POST",
|
||||
"/api/providers",
|
||||
json!({
|
||||
"platform": "openai",
|
||||
"name": "OpenAI Mock",
|
||||
"base_url": mock_server.uri(),
|
||||
"api_key": "test-api-key"
|
||||
}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
let json = body_json(resp).await;
|
||||
let id = json["data"]["id"].as_str().unwrap().to_string();
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
&format!("/api/providers/{id}/models"),
|
||||
json!({"try_fix": false}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
let models = json["data"]["models"].as_array().unwrap();
|
||||
assert_eq!(models.len(), 2);
|
||||
assert_eq!(models[0], "gpt-4o");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_fetch_nonexistent_provider_with_auth() {
|
||||
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("POST", "/api/providers/nonexistent/models", json!({}), &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Protocol detection
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn protocol_detect_openai_with_auth() {
|
||||
let mock_server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/models"))
|
||||
.and(match_header("Authorization", "Bearer sk-test-key"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"data": [{"id": "gpt-4"}, {"id": "gpt-3.5-turbo"}]
|
||||
})))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
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(
|
||||
"POST",
|
||||
"/api/providers/detect-protocol",
|
||||
json!({
|
||||
"base_url": mock_server.uri(),
|
||||
"api_key": "sk-test-key"
|
||||
}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["protocol"], "openai");
|
||||
assert!(json["data"]["confidence"].as_u64().unwrap() > 0);
|
||||
let models = json["data"]["models"].as_array().unwrap();
|
||||
assert!(!models.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn protocol_detect_all_fail_with_auth() {
|
||||
let mock_server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
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(
|
||||
"POST",
|
||||
"/api/providers/detect-protocol",
|
||||
json!({
|
||||
"base_url": mock_server.uri(),
|
||||
"api_key": "sk-unknown"
|
||||
}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["protocol"], "unknown");
|
||||
assert_eq!(json["data"]["confidence"], 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn protocol_detect_validation_with_auth() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// Missing baseUrl
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/providers/detect-protocol",
|
||||
json!({"api_key": "sk-test"}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
// Missing apiKey
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/providers/detect-protocol",
|
||||
json!({"base_url": "https://api.example.com"}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn protocol_detect_switch_platform_suggestion_with_auth() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/models"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"data": [{"id": "gpt-4"}]
|
||||
})))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
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(
|
||||
"POST",
|
||||
"/api/providers/detect-protocol",
|
||||
json!({
|
||||
"base_url": mock_server.uri(),
|
||||
"api_key": "sk-test",
|
||||
"preferred_protocol": "anthropic"
|
||||
}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["protocol"], "openai");
|
||||
assert_eq!(json["data"]["suggestion"]["type"], "switch_platform");
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Settings and client preferences CRUD tests with auth.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use common::{body_json, build_app, get_with_token, json_with_token, setup_and_login};
|
||||
|
||||
// ===========================================================================
|
||||
// Settings CRUD
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn settings_get_default_values_with_auth() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let resp = app.oneshot(get_with_token("/api/settings", &token)).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
assert_eq!(json["data"]["language"], "en-US");
|
||||
assert_eq!(json["data"]["notification_enabled"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn settings_patch_and_get_with_auth() {
|
||||
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/settings",
|
||||
json!({"language": "zh-CN", "notification_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"]["language"], "zh-CN");
|
||||
assert_eq!(json["data"]["notification_enabled"], false);
|
||||
|
||||
let resp = app.oneshot(get_with_token("/api/settings", &token)).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["language"], "zh-CN");
|
||||
assert_eq!(json["data"]["notification_enabled"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn settings_invalid_language_rejected_with_auth() {
|
||||
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/settings",
|
||||
json!({"language": "invalid-lang"}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Client Preferences CRUD
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn client_prefs_empty_then_write_with_auth() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/settings/client", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"], json!({}));
|
||||
|
||||
let req = json_with_token(
|
||||
"PUT",
|
||||
"/api/settings/client",
|
||||
json!({"theme": "dark", "companion.size": 360, "system.closeToTray": true}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/settings/client", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["theme"], "dark");
|
||||
assert_eq!(json["data"]["companion.size"], 360);
|
||||
assert_eq!(json["data"]["system.closeToTray"], true);
|
||||
|
||||
let req = json_with_token("PUT", "/api/settings/client", json!({"theme": null}), &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_token("/api/settings/client", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["data"].get("theme").is_none());
|
||||
assert_eq!(json["data"]["companion.size"], 360);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn client_prefs_key_filter_with_auth() {
|
||||
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/settings/client",
|
||||
json!({"a": 1, "b": 2, "c": 3}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_with_token("/api/settings/client?keys=a,c", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
let data = json["data"].as_object().unwrap();
|
||||
assert_eq!(data.len(), 2);
|
||||
assert_eq!(data["a"], 1);
|
||||
assert_eq!(data["c"], 3);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//! System info, version check, and full system flow E2E tests.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use tower::ServiceExt;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
use common::{
|
||||
body_json, build_app, build_app_with_mock_version, delete_with_token, get_with_token, json_with_token,
|
||||
setup_and_login,
|
||||
};
|
||||
|
||||
// ===========================================================================
|
||||
// System info
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn system_info_with_auth() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let resp = app.oneshot(get_with_token("/api/system/info", &token)).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
|
||||
let data = &json["data"];
|
||||
assert!(data["cache_dir"].as_str().is_some_and(|s| !s.is_empty()));
|
||||
assert!(data["work_dir"].as_str().is_some_and(|s| !s.is_empty()));
|
||||
assert!(data["log_dir"].as_str().is_some_and(|s| !s.is_empty()));
|
||||
assert!(["darwin", "win32", "linux"].contains(&data["platform"].as_str().unwrap()));
|
||||
assert!(["x64", "arm64"].contains(&data["arch"].as_str().unwrap()));
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Version check
|
||||
// ===========================================================================
|
||||
|
||||
fn make_github_release(tag: &str, draft: bool, prerelease: bool, assets: Vec<serde_json::Value>) -> serde_json::Value {
|
||||
json!({
|
||||
"tag_name": tag,
|
||||
"name": format!("Release {tag}"),
|
||||
"body": "Release notes",
|
||||
"html_url": format!("https://github.com/nomifun/nomifun-app/releases/tag/{tag}"),
|
||||
"published_at": "2026-04-01T00:00:00Z",
|
||||
"prerelease": prerelease,
|
||||
"draft": draft,
|
||||
"assets": assets,
|
||||
})
|
||||
}
|
||||
|
||||
fn make_github_asset(name: &str, size: u64) -> serde_json::Value {
|
||||
json!({
|
||||
"name": name,
|
||||
"browser_download_url": format!("https://github.com/download/{name}"),
|
||||
"size": size,
|
||||
"content_type": "application/octet-stream",
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn version_check_has_update_with_auth() {
|
||||
let mock_server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/nomifun/nomifun-app/releases"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!([make_github_release(
|
||||
"v2.0.0",
|
||||
false,
|
||||
false,
|
||||
vec![make_github_asset("app-2.0.0-darwin-arm64.dmg", 80_000_000),]
|
||||
),])))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let (mut app, services) = build_app_with_mock_version("1.0.0", &mock_server).await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = json_with_token("POST", "/api/system/check-update", json!({}), &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["update_available"], true);
|
||||
assert_eq!(json["data"]["latest"]["version"], "2.0.0");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn version_check_no_update_with_auth() {
|
||||
let mock_server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/nomifun/nomifun-app/releases"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!([make_github_release(
|
||||
"v1.0.0",
|
||||
false,
|
||||
false,
|
||||
vec![]
|
||||
),])))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let (mut app, services) = build_app_with_mock_version("1.0.0", &mock_server).await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = json_with_token("POST", "/api/system/check-update", json!({}), &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["update_available"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn version_check_github_error_with_auth() {
|
||||
let mock_server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/nomifun/nomifun-app/releases"))
|
||||
.respond_with(ResponseTemplate::new(500).set_body_string("Internal Error"))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let (mut app, services) = build_app_with_mock_version("1.0.0", &mock_server).await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = json_with_token("POST", "/api/system/check-update", json!({}), &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Full authenticated flow — settings + providers round-trip
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_system_flow_e2e() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// 1. Get default settings
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/settings", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["language"], "en-US");
|
||||
|
||||
// 2. Update language
|
||||
let req = json_with_token("PATCH", "/api/settings", json!({"language": "zh-CN"}), &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"]["language"], "zh-CN");
|
||||
|
||||
// 3. Write client preferences
|
||||
let req = json_with_token(
|
||||
"PUT",
|
||||
"/api/settings/client",
|
||||
json!({"theme": "dark", "sidebar.width": 280}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// 4. Verify preferences
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/settings/client", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["theme"], "dark");
|
||||
assert_eq!(json["data"]["sidebar.width"], 280);
|
||||
|
||||
// 5. Get system info
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/system/info", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["data"]["platform"].as_str().is_some());
|
||||
|
||||
// 6. Create provider
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
"/api/providers",
|
||||
json!({
|
||||
"platform": "openai",
|
||||
"name": "OpenAI",
|
||||
"base_url": "https://api.openai.com",
|
||||
"api_key": "sk-proj-test-key-1234"
|
||||
}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
let json = body_json(resp).await;
|
||||
let provider_id = json["data"]["id"].as_str().unwrap().to_string();
|
||||
assert_eq!(json["data"]["api_key"], "sk-proj-test-key-1234");
|
||||
|
||||
// 7. List providers
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/providers", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"].as_array().unwrap().len(), 1);
|
||||
|
||||
// 8. Delete provider
|
||||
let resp = app
|
||||
.oneshot(delete_with_token(
|
||||
&format!("/api/providers/{provider_id}"),
|
||||
&token,
|
||||
&csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
@@ -0,0 +1,842 @@
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use common::{
|
||||
body_json, build_app, build_app_with_mock_agents, delete_with_token, get_with_token, json_with_token,
|
||||
setup_and_login,
|
||||
};
|
||||
|
||||
fn two_agent_body() -> serde_json::Value {
|
||||
json!({
|
||||
"name": "Alpha",
|
||||
"agents": [
|
||||
{ "name": "Lead", "role": "lead", "backend": "acp", "model": "claude" },
|
||||
{ "name": "Worker", "role": "teammate", "backend": "acp", "model": "claude" }
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_team(app: &mut axum::Router, token: &str, csrf: &str) -> serde_json::Value {
|
||||
let req = json_with_token("POST", "/api/teams", two_agent_body(), token, csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["success"].as_bool().unwrap());
|
||||
json["data"].clone()
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// §1 Team CRUD (TC-*, TL-*, TG-*, TD-*, TR-*)
|
||||
// ===========================================================================
|
||||
|
||||
// TC-1: Create team with multiple agents
|
||||
#[tokio::test]
|
||||
async fn tc1_create_team_with_multiple_agents() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let data = create_team(&mut app, &token, &csrf).await;
|
||||
assert_eq!(data["name"], "Alpha");
|
||||
assert_eq!(data["agents"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(data["agents"][0]["role"], "lead");
|
||||
assert_eq!(data["agents"][1]["role"], "teammate");
|
||||
assert!(data["lead_agent_id"].is_string());
|
||||
assert_eq!(data["lead_agent_id"], data["agents"][0]["slot_id"]);
|
||||
}
|
||||
|
||||
// TC-2: Create single agent team
|
||||
#[tokio::test]
|
||||
async fn tc2_create_single_agent_team() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let body = json!({
|
||||
"name": "Solo",
|
||||
"agents": [{ "name": "Lead", "role": "lead", "backend": "acp", "model": "claude" }]
|
||||
});
|
||||
let req = json_with_token("POST", "/api/teams", body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["agents"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
// TC-3: Each agent has a conversation
|
||||
#[tokio::test]
|
||||
async fn tc3_each_agent_has_conversation_id() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let data = create_team(&mut app, &token, &csrf).await;
|
||||
for agent in data["agents"].as_array().unwrap() {
|
||||
assert!(agent["conversation_id"].is_i64());
|
||||
assert!(agent["conversation_id"].as_i64().unwrap() > 0);
|
||||
}
|
||||
assert_ne!(
|
||||
data["agents"][0]["conversation_id"],
|
||||
data["agents"][1]["conversation_id"]
|
||||
);
|
||||
}
|
||||
|
||||
// TC-4: First agent defaults to lead
|
||||
#[tokio::test]
|
||||
async fn tc4_first_agent_is_lead() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let body = json!({
|
||||
"name": "T",
|
||||
"agents": [
|
||||
{ "name": "A", "role": "teammate", "backend": "acp", "model": "claude" },
|
||||
{ "name": "B", "role": "teammate", "backend": "acp", "model": "claude" }
|
||||
]
|
||||
});
|
||||
let req = json_with_token("POST", "/api/teams", body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["agents"][0]["role"], "lead");
|
||||
assert_eq!(json["data"]["lead_agent_id"], json["data"]["agents"][0]["slot_id"]);
|
||||
}
|
||||
|
||||
// TC-5: Empty agents returns 400
|
||||
#[tokio::test]
|
||||
async fn tc5_empty_agents_returns_error() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let body = json!({ "name": "Empty", "agents": [] });
|
||||
let req = json_with_token("POST", "/api/teams", body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// TC-6: Missing name returns 400
|
||||
#[tokio::test]
|
||||
async fn tc6_missing_name_returns_error() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let body = json!({ "agents": [{ "name": "L", "role": "lead", "backend": "acp", "model": "c" }] });
|
||||
let req = json_with_token("POST", "/api/teams", body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tc6b_workspace_with_edge_whitespace_segment_returns_specific_code() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let body = json!({
|
||||
"name": "Alpha",
|
||||
"workspace": "/Users/zhoukai/Documents/Archive ",
|
||||
"agents": [{ "name": "Lead", "role": "lead", "backend": "acp", "model": "claude" }]
|
||||
});
|
||||
let req = json_with_token("POST", "/api/teams", body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["code"], "WORKSPACE_PATH_EDGE_WHITESPACE_UNSUPPORTED");
|
||||
assert!(
|
||||
json["error"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("begins or ends with whitespace")
|
||||
);
|
||||
}
|
||||
|
||||
// TC-7: Unauthenticated returns 403
|
||||
#[tokio::test]
|
||||
async fn tc7_unauthenticated_returns_403() {
|
||||
let (app, _services) = build_app().await;
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/teams")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// TL-1: Empty team list
|
||||
#[tokio::test]
|
||||
async fn tl1_empty_team_list() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = get_with_token("/api/teams", &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["data"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
// TL-2: List multiple teams
|
||||
#[tokio::test]
|
||||
async fn tl2_list_multiple_teams() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
create_team(&mut app, &token, &csrf).await;
|
||||
|
||||
let body = json!({
|
||||
"name": "Beta",
|
||||
"agents": [{ "name": "Lead", "role": "lead", "backend": "acp", "model": "claude" }]
|
||||
});
|
||||
let req = json_with_token("POST", "/api/teams", body, &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
|
||||
let req = get_with_token("/api/teams", &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"].as_array().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
// TL-3: Each team contains full agents info
|
||||
#[tokio::test]
|
||||
async fn tl3_teams_contain_full_agent_info() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
create_team(&mut app, &token, &csrf).await;
|
||||
|
||||
let req = get_with_token("/api/teams", &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
let teams = json["data"].as_array().unwrap();
|
||||
let agent = &teams[0]["agents"][0];
|
||||
assert!(agent["slot_id"].is_string());
|
||||
assert!(agent["name"].is_string());
|
||||
assert!(agent["role"].is_string());
|
||||
assert!(agent["conversation_id"].is_i64());
|
||||
assert!(agent["backend"].is_string());
|
||||
assert!(agent["model"].is_string());
|
||||
}
|
||||
|
||||
// TG-1: Get existing team
|
||||
#[tokio::test]
|
||||
async fn tg1_get_existing_team() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let data = create_team(&mut app, &token, &csrf).await;
|
||||
let team_id = data["id"].as_str().unwrap();
|
||||
|
||||
let req = get_with_token(&format!("/api/teams/{team_id}"), &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["id"], team_id);
|
||||
assert_eq!(json["data"]["name"], "Alpha");
|
||||
}
|
||||
|
||||
// TG-2: Get nonexistent team returns 404
|
||||
#[tokio::test]
|
||||
async fn tg2_get_nonexistent_returns_404() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = get_with_token("/api/teams/nonexistent", &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// TD-1: Delete existing team
|
||||
#[tokio::test]
|
||||
async fn td1_delete_existing_team() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let data = create_team(&mut app, &token, &csrf).await;
|
||||
let team_id = data["id"].as_str().unwrap();
|
||||
|
||||
let req = delete_with_token(&format!("/api/teams/{team_id}"), &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
// TD-2: Delete then list confirms removal
|
||||
#[tokio::test]
|
||||
async fn td2_delete_then_list_empty() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let data = create_team(&mut app, &token, &csrf).await;
|
||||
let team_id = data["id"].as_str().unwrap();
|
||||
|
||||
let req = delete_with_token(&format!("/api/teams/{team_id}"), &token, &csrf);
|
||||
app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
let req = get_with_token("/api/teams", &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["data"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
// TD-6: Delete nonexistent team returns 404
|
||||
#[tokio::test]
|
||||
async fn td6_delete_nonexistent_returns_404() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = delete_with_token("/api/teams/nonexistent", &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// TR-1: Rename existing team
|
||||
#[tokio::test]
|
||||
async fn tr1_rename_existing_team() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let data = create_team(&mut app, &token, &csrf).await;
|
||||
let team_id = data["id"].as_str().unwrap();
|
||||
|
||||
let req = json_with_token(
|
||||
"PATCH",
|
||||
&format!("/api/teams/{team_id}/name"),
|
||||
json!({ "name": "New Name" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
// TR-2: Rename then get confirms new name
|
||||
#[tokio::test]
|
||||
async fn tr2_rename_then_get_confirms_new_name() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let data = create_team(&mut app, &token, &csrf).await;
|
||||
let team_id = data["id"].as_str().unwrap();
|
||||
|
||||
let req = json_with_token(
|
||||
"PATCH",
|
||||
&format!("/api/teams/{team_id}/name"),
|
||||
json!({ "name": "New Name" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
let req = get_with_token(&format!("/api/teams/{team_id}"), &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["name"], "New Name");
|
||||
}
|
||||
|
||||
// TR-4: Rename nonexistent team returns 404
|
||||
#[tokio::test]
|
||||
async fn tr4_rename_nonexistent_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/teams/nonexistent/name",
|
||||
json!({ "name": "X" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// §2 Agent Management (AA-*, AR-*, AN-*)
|
||||
// ===========================================================================
|
||||
|
||||
// AA-1: Add agent to team
|
||||
#[tokio::test]
|
||||
async fn aa1_add_agent_to_team() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let data = create_team(&mut app, &token, &csrf).await;
|
||||
let team_id = data["id"].as_str().unwrap();
|
||||
|
||||
let body = json!({
|
||||
"name": "New Agent",
|
||||
"role": "teammate",
|
||||
"backend": "acp",
|
||||
"model": "claude"
|
||||
});
|
||||
let req = json_with_token("POST", &format!("/api/teams/{team_id}/agents"), body, &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["name"], "New Agent");
|
||||
assert!(json["data"]["conversation_id"].is_i64());
|
||||
}
|
||||
|
||||
// AA-2: After adding, agent count increases
|
||||
#[tokio::test]
|
||||
async fn aa2_add_agent_increases_count() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let data = create_team(&mut app, &token, &csrf).await;
|
||||
let team_id = data["id"].as_str().unwrap();
|
||||
|
||||
let body = json!({ "name": "X", "role": "teammate", "backend": "acp", "model": "claude" });
|
||||
let req = json_with_token("POST", &format!("/api/teams/{team_id}/agents"), body, &token, &csrf);
|
||||
app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
let req = get_with_token(&format!("/api/teams/{team_id}"), &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["agents"].as_array().unwrap().len(), 3);
|
||||
}
|
||||
|
||||
// AA-4: Add agent to nonexistent team returns 404
|
||||
#[tokio::test]
|
||||
async fn aa4_add_agent_nonexistent_team() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let body = json!({ "name": "X", "role": "teammate", "backend": "acp", "model": "claude" });
|
||||
let req = json_with_token("POST", "/api/teams/nonexistent/agents", body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// AA-5: Missing required fields returns 400
|
||||
#[tokio::test]
|
||||
async fn aa5_add_agent_missing_fields() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let data = create_team(&mut app, &token, &csrf).await;
|
||||
let team_id = data["id"].as_str().unwrap();
|
||||
|
||||
let body = json!({ "role": "teammate", "backend": "acp" });
|
||||
let req = json_with_token("POST", &format!("/api/teams/{team_id}/agents"), body, &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// AR-1: Remove agent from team
|
||||
#[tokio::test]
|
||||
async fn ar1_remove_agent_from_team() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let data = create_team(&mut app, &token, &csrf).await;
|
||||
let team_id = data["id"].as_str().unwrap();
|
||||
let slot_id = data["agents"][1]["slot_id"].as_str().unwrap();
|
||||
|
||||
let req = delete_with_token(&format!("/api/teams/{team_id}/agents/{slot_id}"), &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
// AR-2: After removal, agent not in team
|
||||
#[tokio::test]
|
||||
async fn ar2_after_removal_agent_gone() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let data = create_team(&mut app, &token, &csrf).await;
|
||||
let team_id = data["id"].as_str().unwrap();
|
||||
let slot_id = data["agents"][1]["slot_id"].as_str().unwrap();
|
||||
|
||||
let req = delete_with_token(&format!("/api/teams/{team_id}/agents/{slot_id}"), &token, &csrf);
|
||||
app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
let req = get_with_token(&format!("/api/teams/{team_id}"), &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
let agents = json["data"]["agents"].as_array().unwrap();
|
||||
assert_eq!(agents.len(), 1);
|
||||
assert!(agents.iter().all(|a| a["slot_id"] != slot_id));
|
||||
}
|
||||
|
||||
// AR-4: Remove nonexistent agent returns 404
|
||||
#[tokio::test]
|
||||
async fn ar4_remove_nonexistent_agent() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let data = create_team(&mut app, &token, &csrf).await;
|
||||
let team_id = data["id"].as_str().unwrap();
|
||||
|
||||
let req = delete_with_token(&format!("/api/teams/{team_id}/agents/nonexistent"), &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// AN-1: Rename agent
|
||||
#[tokio::test]
|
||||
async fn an1_rename_agent() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let data = create_team(&mut app, &token, &csrf).await;
|
||||
let team_id = data["id"].as_str().unwrap();
|
||||
let slot_id = data["agents"][1]["slot_id"].as_str().unwrap();
|
||||
|
||||
let req = json_with_token(
|
||||
"PATCH",
|
||||
&format!("/api/teams/{team_id}/agents/{slot_id}/name"),
|
||||
json!({ "name": "Senior Worker" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
// AN-2: Rename then get confirms new name
|
||||
#[tokio::test]
|
||||
async fn an2_rename_then_get_confirms_name() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let data = create_team(&mut app, &token, &csrf).await;
|
||||
let team_id = data["id"].as_str().unwrap();
|
||||
let slot_id = data["agents"][1]["slot_id"].as_str().unwrap();
|
||||
|
||||
let req = json_with_token(
|
||||
"PATCH",
|
||||
&format!("/api/teams/{team_id}/agents/{slot_id}/name"),
|
||||
json!({ "name": "Senior Worker" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
let req = get_with_token(&format!("/api/teams/{team_id}"), &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
let agents = json["data"]["agents"].as_array().unwrap();
|
||||
let agent = agents.iter().find(|a| a["slot_id"] == slot_id).unwrap();
|
||||
assert_eq!(agent["name"], "Senior Worker");
|
||||
}
|
||||
|
||||
// AN-3: Rename nonexistent agent returns 404
|
||||
#[tokio::test]
|
||||
async fn an3_rename_nonexistent_agent() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let data = create_team(&mut app, &token, &csrf).await;
|
||||
let team_id = data["id"].as_str().unwrap();
|
||||
|
||||
let req = json_with_token(
|
||||
"PATCH",
|
||||
&format!("/api/teams/{team_id}/agents/nonexistent/name"),
|
||||
json!({ "name": "X" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// §3 Session Management (ES-*, SS-*)
|
||||
// ===========================================================================
|
||||
|
||||
// ES-1: Ensure session
|
||||
#[tokio::test]
|
||||
async fn es1_ensure_session() {
|
||||
let (mut app, services) = build_app_with_mock_agents().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let data = create_team(&mut app, &token, &csrf).await;
|
||||
let team_id = data["id"].as_str().unwrap();
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
&format!("/api/teams/{team_id}/session"),
|
||||
json!({}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
// ES-2: Ensure session is idempotent
|
||||
#[tokio::test]
|
||||
async fn es2_ensure_session_idempotent() {
|
||||
let (mut app, services) = build_app_with_mock_agents().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let data = create_team(&mut app, &token, &csrf).await;
|
||||
let team_id = data["id"].as_str().unwrap();
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
&format!("/api/teams/{team_id}/session"),
|
||||
json!({}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
&format!("/api/teams/{team_id}/session"),
|
||||
json!({}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
// ES-3: Ensure session for nonexistent team returns 404
|
||||
#[tokio::test]
|
||||
async fn es3_ensure_session_nonexistent() {
|
||||
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("POST", "/api/teams/nonexistent/session", json!({}), &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// SS-1: Stop session
|
||||
#[tokio::test]
|
||||
async fn ss1_stop_session() {
|
||||
let (mut app, services) = build_app_with_mock_agents().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let data = create_team(&mut app, &token, &csrf).await;
|
||||
let team_id = data["id"].as_str().unwrap();
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
&format!("/api/teams/{team_id}/session"),
|
||||
json!({}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
let req = delete_with_token(&format!("/api/teams/{team_id}/session"), &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
// SS-3: Stop session without active is noop
|
||||
#[tokio::test]
|
||||
async fn ss3_stop_session_noop() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let data = create_team(&mut app, &token, &csrf).await;
|
||||
let team_id = data["id"].as_str().unwrap();
|
||||
|
||||
let req = delete_with_token(&format!("/api/teams/{team_id}/session"), &token, &csrf);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// §4 Message sending (SM-*, SA-*)
|
||||
// ===========================================================================
|
||||
|
||||
// SM-1: Send message with active session
|
||||
#[tokio::test]
|
||||
async fn sm1_send_message_with_session() {
|
||||
let (mut app, services) = build_app_with_mock_agents().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let data = create_team(&mut app, &token, &csrf).await;
|
||||
let team_id = data["id"].as_str().unwrap();
|
||||
|
||||
// Start session first
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
&format!("/api/teams/{team_id}/session"),
|
||||
json!({}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
&format!("/api/teams/{team_id}/messages"),
|
||||
json!({ "content": "Hello team" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
// SM-4: Send message without session returns 404
|
||||
#[tokio::test]
|
||||
async fn sm4_send_message_no_session() {
|
||||
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(
|
||||
"POST",
|
||||
"/api/teams/nonexistent/messages",
|
||||
json!({ "content": "Hello" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// SM-5: Missing content returns 400
|
||||
#[tokio::test]
|
||||
async fn sm5_send_message_missing_content() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let data = create_team(&mut app, &token, &csrf).await;
|
||||
let team_id = data["id"].as_str().unwrap();
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
&format!("/api/teams/{team_id}/messages"),
|
||||
json!({}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// SA-1: Send message to specific agent
|
||||
#[tokio::test]
|
||||
async fn sa1_send_message_to_agent() {
|
||||
let (mut app, services) = build_app_with_mock_agents().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let data = create_team(&mut app, &token, &csrf).await;
|
||||
let team_id = data["id"].as_str().unwrap();
|
||||
let slot_id = data["agents"][1]["slot_id"].as_str().unwrap();
|
||||
|
||||
// Start session first
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
&format!("/api/teams/{team_id}/session"),
|
||||
json!({}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
&format!("/api/teams/{team_id}/agents/{slot_id}/messages"),
|
||||
json!({ "content": "Do this" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// §5 Full lifecycle
|
||||
// ===========================================================================
|
||||
|
||||
// Full CRUD lifecycle
|
||||
#[tokio::test]
|
||||
async fn full_team_lifecycle() {
|
||||
let (mut app, services) = build_app_with_mock_agents().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// Create
|
||||
let data = create_team(&mut app, &token, &csrf).await;
|
||||
let team_id = data["id"].as_str().unwrap();
|
||||
assert_eq!(data["agents"].as_array().unwrap().len(), 2);
|
||||
|
||||
// Add agent
|
||||
let body = json!({ "name": "Helper", "role": "teammate", "backend": "acp", "model": "claude" });
|
||||
let req = json_with_token("POST", &format!("/api/teams/{team_id}/agents"), body, &token, &csrf);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
let added = body_json(resp).await;
|
||||
let new_slot = added["data"]["slot_id"].as_str().unwrap().to_owned();
|
||||
|
||||
// Verify 3 agents
|
||||
let req = get_with_token(&format!("/api/teams/{team_id}"), &token);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["agents"].as_array().unwrap().len(), 3);
|
||||
|
||||
// Rename team
|
||||
let req = json_with_token(
|
||||
"PATCH",
|
||||
&format!("/api/teams/{team_id}/name"),
|
||||
json!({ "name": "Renamed" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
// Rename agent
|
||||
let req = json_with_token(
|
||||
"PATCH",
|
||||
&format!("/api/teams/{team_id}/agents/{new_slot}/name"),
|
||||
json!({ "name": "Senior Helper" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
// Ensure session
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
&format!("/api/teams/{team_id}/session"),
|
||||
json!({}),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
// Send message
|
||||
let req = json_with_token(
|
||||
"POST",
|
||||
&format!("/api/teams/{team_id}/messages"),
|
||||
json!({ "content": "Hello" }),
|
||||
&token,
|
||||
&csrf,
|
||||
);
|
||||
app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
// Stop session
|
||||
let req = delete_with_token(&format!("/api/teams/{team_id}/session"), &token, &csrf);
|
||||
app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
// Remove added agent
|
||||
let req = delete_with_token(&format!("/api/teams/{team_id}/agents/{new_slot}"), &token, &csrf);
|
||||
app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
// Verify 2 agents remain
|
||||
let req = get_with_token(&format!("/api/teams/{team_id}"), &token);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["agents"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(json["data"]["name"], "Renamed");
|
||||
|
||||
// Delete team
|
||||
let req = delete_with_token(&format!("/api/teams/{team_id}"), &token, &csrf);
|
||||
app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
// Verify empty
|
||||
let req = get_with_token("/api/teams", &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["data"].as_array().unwrap().is_empty());
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//! D11 — Wave-2 app assembly smoke test.
|
||||
//!
|
||||
//! Minimum guarantee: after D7/D8/D9/D10 merged, `AppServices` composes into
|
||||
//! a router that actually exposes the `/api/teams` endpoints. Anything beyond
|
||||
//! compile-check is validated by `team_e2e.rs`; this file is kept intentionally
|
||||
//! tiny so assembly regressions surface first.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use common::{build_app, get_with_token, setup_and_login};
|
||||
|
||||
/// Router boots and `/api/teams` is wired through `build_team_state` into
|
||||
/// `nomifun_team::team_routes`. If the team module failed to assemble, the
|
||||
/// route would 404 (or compile would have failed earlier).
|
||||
#[tokio::test]
|
||||
async fn phase1_router_assembles_with_team_module() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = get_with_token("/api/teams", &token);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"GET /api/teams must be wired through build_team_state"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
//! E2E tests for the Webhook management + tag-settings + tag-bindings endpoints.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use common::{body_json, build_app, delete_with_token, get_request, get_with_token, json_with_token, setup_and_login};
|
||||
|
||||
#[tokio::test]
|
||||
async fn unauthenticated_webhook_list_is_rejected() {
|
||||
let (app, _services) = build_app().await;
|
||||
let resp = app.oneshot(get_request("/api/webhooks")).await.unwrap();
|
||||
assert!(
|
||||
resp.status() == StatusCode::UNAUTHORIZED || resp.status() == StatusCode::FORBIDDEN,
|
||||
"expected 401/403, got {}",
|
||||
resp.status()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn webhook_crud_and_secret_is_hidden() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// create (with a secret)
|
||||
let body = json!({
|
||||
"name": "Team bot",
|
||||
"url": "https://open.feishu.cn/open-apis/bot/v2/hook/abc",
|
||||
"platform": "lark",
|
||||
"description": "notify",
|
||||
"secret": "s3cr3t",
|
||||
"enabled": true
|
||||
});
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token("POST", "/api/webhooks", body, &token, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
let json = body_json(resp).await;
|
||||
let id = json["data"]["id"].as_i64().unwrap();
|
||||
assert_eq!(json["data"]["name"], "Team bot");
|
||||
// secret must NOT be echoed; has_secret signals presence.
|
||||
assert_eq!(json["data"]["has_secret"], true);
|
||||
assert!(json["data"].get("secret").is_none(), "secret must never be returned");
|
||||
|
||||
// list
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/webhooks", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert_eq!(body_json(resp).await["data"].as_array().unwrap().len(), 1);
|
||||
|
||||
// update: rename + clear secret
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token(
|
||||
"PUT",
|
||||
&format!("/api/webhooks/{id}"),
|
||||
json!({ "name": "Renamed", "secret": null, "enabled": false }),
|
||||
&token,
|
||||
&csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["name"], "Renamed");
|
||||
assert_eq!(json["data"]["has_secret"], false);
|
||||
assert_eq!(json["data"]["enabled"], false);
|
||||
|
||||
// delete
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(delete_with_token(&format!("/api/webhooks/{id}"), &token, &csrf))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// get after delete → 404
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token(&format!("/api/webhooks/{id}"), &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn webhook_create_validates_required_fields() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token(
|
||||
"POST",
|
||||
"/api/webhooks",
|
||||
json!({ "name": " ", "url": "https://x" }),
|
||||
&token,
|
||||
&csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn webhook_test_unreachable_url_is_bad_gateway() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
// create a webhook pointing at an unroutable address.
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token(
|
||||
"POST",
|
||||
"/api/webhooks",
|
||||
json!({ "name": "bad", "url": "http://127.0.0.1:1/hook", "platform": "lark" }),
|
||||
&token,
|
||||
&csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let id = body_json(resp).await["data"]["id"].as_i64().unwrap();
|
||||
|
||||
// /test invokes the sender; the connection fails → 502 Bad Gateway. This
|
||||
// proves the route + sender are wired (we can't reach real Lark in tests).
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token(
|
||||
"POST",
|
||||
&format!("/api/webhooks/{id}/test"),
|
||||
json!({}),
|
||||
&token,
|
||||
&csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tag_settings_get_default_and_upsert() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// unset tag → default (unbound) shape
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/tags/alpha/settings", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["tag"], "alpha");
|
||||
assert!(json["data"]["webhook_id"].is_null());
|
||||
|
||||
// create a webhook to bind
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token(
|
||||
"POST",
|
||||
"/api/webhooks",
|
||||
json!({ "name": "wh", "url": "https://x/hook", "platform": "lark" }),
|
||||
&token,
|
||||
&csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let wh_id = body_json(resp).await["data"]["id"].as_i64().unwrap();
|
||||
|
||||
// bind it to the tag
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token(
|
||||
"PUT",
|
||||
"/api/tags/alpha/settings",
|
||||
json!({ "webhook_id": wh_id, "description": "queue alpha" }),
|
||||
&token,
|
||||
&csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["data"]["webhook_id"], wh_id);
|
||||
assert_eq!(json["data"]["description"], "queue alpha");
|
||||
|
||||
// binding a non-existent webhook → 400
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token(
|
||||
"PUT",
|
||||
"/api/tags/alpha/settings",
|
||||
json!({ "webhook_id": 999999 }),
|
||||
&token,
|
||||
&csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tag_bindings_lists_enabled_autowork_conversations() {
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
// empty initially
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/requirements/tag-bindings", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert_eq!(body_json(resp).await["data"].as_array().unwrap().len(), 0);
|
||||
|
||||
// create a conversation, then enable AutoWork on it for tag "x"
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token(
|
||||
"POST",
|
||||
"/api/conversations",
|
||||
json!({ "type": "acp", "name": "Conv X", "extra": { "workspace": "/project" } }),
|
||||
&token,
|
||||
&csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
let conv_id = body_json(resp).await["data"]["id"].as_i64().unwrap().to_string();
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token(
|
||||
"POST",
|
||||
"/api/requirements/autowork",
|
||||
json!({ "kind": "conversation", "target_id": conv_id, "enabled": true, "tag": "x" }),
|
||||
&token,
|
||||
&csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// tag-bindings now groups the conversation under "x"
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_token("/api/requirements/tag-bindings", &token))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
let groups = json["data"].as_array().unwrap();
|
||||
let x = groups.iter().find(|g| g["tag"] == "x").expect("tag x present");
|
||||
assert_eq!(x["bindings"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(x["bindings"][0]["target_id"], conv_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_disable_of_idle_target_is_allowed() {
|
||||
// from_admin disable of an idle (not actively executing) target succeeds —
|
||||
// the guard only blocks active targets, which require a live in-progress
|
||||
// requirement that this lightweight test does not set up.
|
||||
let (mut app, services) = build_app().await;
|
||||
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token(
|
||||
"POST",
|
||||
"/api/conversations",
|
||||
json!({ "type": "acp", "name": "Conv Y", "extra": { "workspace": "/project" } }),
|
||||
&token,
|
||||
&csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let conv_id = body_json(resp).await["data"]["id"].as_i64().unwrap().to_string();
|
||||
|
||||
// enable then admin-disable (idle) → both OK
|
||||
for enabled in [true, false] {
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_with_token(
|
||||
"POST",
|
||||
"/api/requirements/autowork",
|
||||
json!({ "kind": "conversation", "target_id": conv_id, "enabled": enabled, "tag": "x", "from_admin": true }),
|
||||
&token,
|
||||
&csrf,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"enabled={enabled} should be allowed for an idle target"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
//! End-to-end WebSocket integration tests through the full app stack.
|
||||
//!
|
||||
//! Tests exercise real JWT auth, token extraction from HTTP headers,
|
||||
//! message routing, broadcast/unicast, and connection lifecycle.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use nomifun_api_types::WebSocketMessage;
|
||||
use nomifun_app::{AppConfig, AppServices, create_router};
|
||||
use nomifun_realtime::WebSocketManager;
|
||||
use serde_json::{Value, json};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_tungstenite::tungstenite;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct TestApp {
|
||||
addr: SocketAddr,
|
||||
services: AppServices,
|
||||
}
|
||||
|
||||
async fn start_app() -> TestApp {
|
||||
let db = nomifun_db::init_database_memory().await.unwrap();
|
||||
let services = AppServices::from_config(db, &AppConfig::default()).await.unwrap();
|
||||
let router = create_router(&services).await;
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, router).await.unwrap();
|
||||
});
|
||||
|
||||
TestApp { addr, services }
|
||||
}
|
||||
|
||||
/// Sign a valid JWT token for testing.
|
||||
fn sign_token(app: &TestApp, user_id: &str) -> String {
|
||||
app.services.jwt_service.sign(user_id, "testuser").unwrap()
|
||||
}
|
||||
|
||||
/// Connect with an Authorization: Bearer header.
|
||||
async fn connect_bearer(
|
||||
addr: SocketAddr,
|
||||
token: &str,
|
||||
) -> (
|
||||
futures_util::stream::SplitSink<
|
||||
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
|
||||
tungstenite::Message,
|
||||
>,
|
||||
futures_util::stream::SplitStream<
|
||||
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
|
||||
>,
|
||||
) {
|
||||
let url = format!("ws://{addr}/ws");
|
||||
let request = tungstenite::http::Request::builder()
|
||||
.uri(&url)
|
||||
.header("Host", addr.to_string())
|
||||
.header("Connection", "Upgrade")
|
||||
.header("Upgrade", "websocket")
|
||||
.header("Sec-WebSocket-Version", "13")
|
||||
.header("Sec-WebSocket-Key", tungstenite::handshake::client::generate_key())
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.body(())
|
||||
.unwrap();
|
||||
|
||||
let (ws, _) = tokio_tungstenite::connect_async(request).await.unwrap();
|
||||
ws.split()
|
||||
}
|
||||
|
||||
/// Connect with a Cookie header.
|
||||
async fn connect_cookie(
|
||||
addr: SocketAddr,
|
||||
token: &str,
|
||||
) -> (
|
||||
futures_util::stream::SplitSink<
|
||||
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
|
||||
tungstenite::Message,
|
||||
>,
|
||||
futures_util::stream::SplitStream<
|
||||
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
|
||||
>,
|
||||
) {
|
||||
let url = format!("ws://{addr}/ws");
|
||||
let request = tungstenite::http::Request::builder()
|
||||
.uri(&url)
|
||||
.header("Host", addr.to_string())
|
||||
.header("Connection", "Upgrade")
|
||||
.header("Upgrade", "websocket")
|
||||
.header("Sec-WebSocket-Version", "13")
|
||||
.header("Sec-WebSocket-Key", tungstenite::handshake::client::generate_key())
|
||||
.header("Cookie", format!("nomifun-session={token}"))
|
||||
.body(())
|
||||
.unwrap();
|
||||
|
||||
let (ws, _) = tokio_tungstenite::connect_async(request).await.unwrap();
|
||||
ws.split()
|
||||
}
|
||||
|
||||
/// Connect with Sec-WebSocket-Protocol header (token as subprotocol).
|
||||
async fn connect_protocol(
|
||||
addr: SocketAddr,
|
||||
token: &str,
|
||||
) -> (
|
||||
futures_util::stream::SplitSink<
|
||||
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
|
||||
tungstenite::Message,
|
||||
>,
|
||||
futures_util::stream::SplitStream<
|
||||
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
|
||||
>,
|
||||
) {
|
||||
let url = format!("ws://{addr}/ws");
|
||||
let request = tungstenite::http::Request::builder()
|
||||
.uri(&url)
|
||||
.header("Host", addr.to_string())
|
||||
.header("Connection", "Upgrade")
|
||||
.header("Upgrade", "websocket")
|
||||
.header("Sec-WebSocket-Version", "13")
|
||||
.header("Sec-WebSocket-Key", tungstenite::handshake::client::generate_key())
|
||||
.header("Sec-WebSocket-Protocol", token)
|
||||
.body(())
|
||||
.unwrap();
|
||||
|
||||
let (ws, _) = tokio_tungstenite::connect_async(request).await.unwrap();
|
||||
ws.split()
|
||||
}
|
||||
|
||||
/// Connect with no auth headers at all.
|
||||
async fn connect_no_auth(
|
||||
addr: SocketAddr,
|
||||
) -> tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>> {
|
||||
let url = format!("ws://{addr}/ws");
|
||||
let (ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap();
|
||||
ws
|
||||
}
|
||||
|
||||
/// Read the next text message within a timeout, returning parsed JSON.
|
||||
async fn read_text<S>(stream: &mut S) -> Value
|
||||
where
|
||||
S: StreamExt<Item = Result<tungstenite::Message, tungstenite::Error>> + Unpin,
|
||||
{
|
||||
let timeout = Duration::from_secs(5);
|
||||
tokio::time::timeout(timeout, async {
|
||||
loop {
|
||||
match stream.next().await {
|
||||
Some(Ok(tungstenite::Message::Text(t))) => {
|
||||
return serde_json::from_str::<Value>(&t).unwrap();
|
||||
}
|
||||
Some(Ok(tungstenite::Message::Close(_))) => {
|
||||
panic!("unexpected close frame while reading text");
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
panic!("read error: {e}");
|
||||
}
|
||||
None => {
|
||||
panic!("stream ended");
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("read_text timed out")
|
||||
}
|
||||
|
||||
/// Read until a close frame is received, returning the close code.
|
||||
async fn read_close<S>(stream: &mut S) -> Option<u16>
|
||||
where
|
||||
S: StreamExt<Item = Result<tungstenite::Message, tungstenite::Error>> + Unpin,
|
||||
{
|
||||
let timeout = Duration::from_secs(5);
|
||||
tokio::time::timeout(timeout, async {
|
||||
loop {
|
||||
match stream.next().await {
|
||||
Some(Ok(tungstenite::Message::Close(frame))) => {
|
||||
return frame.map(|f| f.code.into());
|
||||
}
|
||||
Some(Ok(_)) => continue,
|
||||
Some(Err(_)) => return None,
|
||||
None => return None,
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("read_close timed out")
|
||||
}
|
||||
|
||||
fn send_json(text: &str) -> tungstenite::Message {
|
||||
tungstenite::Message::Text(text.into())
|
||||
}
|
||||
|
||||
fn ws_manager(app: &TestApp) -> &Arc<WebSocketManager> {
|
||||
&app.services.ws_manager
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// T1 — Connection establishment and authentication
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn t1_1_valid_bearer_token_connects() {
|
||||
let app = start_app().await;
|
||||
let token = sign_token(&app, "user1");
|
||||
|
||||
let (_tx, _rx) = connect_bearer(app.addr, &token).await;
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
assert_eq!(ws_manager(&app).client_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t1_2_no_token_closes_1008() {
|
||||
let app = start_app().await;
|
||||
let mut ws = connect_no_auth(app.addr).await;
|
||||
|
||||
let code = read_close(&mut ws).await;
|
||||
assert_eq!(code, Some(1008));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t1_3_invalid_token_sends_auth_expired_then_closes() {
|
||||
let app = start_app().await;
|
||||
|
||||
let (_, mut rx) = connect_bearer(app.addr, "invalid-token").await;
|
||||
|
||||
let msg = read_text(&mut rx).await;
|
||||
assert_eq!(msg["name"], "auth-expired");
|
||||
assert!(msg["data"]["message"].as_str().is_some());
|
||||
|
||||
let code = read_close(&mut rx).await;
|
||||
assert_eq!(code, Some(1008));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t1_4_token_from_cookie() {
|
||||
let app = start_app().await;
|
||||
let token = sign_token(&app, "user1");
|
||||
|
||||
let (_tx, _rx) = connect_cookie(app.addr, &token).await;
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
assert_eq!(ws_manager(&app).client_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t1_5_token_from_sec_websocket_protocol() {
|
||||
let app = start_app().await;
|
||||
let token = sign_token(&app, "user1");
|
||||
|
||||
let (_tx, _rx) = connect_protocol(app.addr, &token).await;
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
assert_eq!(ws_manager(&app).client_count(), 1);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// T3 — Message format
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn t3_1_valid_json_message_accepted() {
|
||||
let app = start_app().await;
|
||||
let token = sign_token(&app, "user1");
|
||||
|
||||
let (mut tx, _rx) = connect_bearer(app.addr, &token).await;
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
let msg = json!({"name": "some-event", "data": {"key": "value"}});
|
||||
tx.send(send_json(&msg.to_string())).await.unwrap();
|
||||
|
||||
// No error response expected — verify with a short timeout
|
||||
let timeout_result = tokio::time::timeout(Duration::from_millis(200), _rx.into_future()).await;
|
||||
// Timeout (no response) is expected for valid messages routed to NoopMessageRouter
|
||||
assert!(timeout_result.is_err(), "valid message should not generate a response");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t3_2_invalid_json_returns_error() {
|
||||
let app = start_app().await;
|
||||
let token = sign_token(&app, "user1");
|
||||
|
||||
let (mut tx, mut rx) = connect_bearer(app.addr, &token).await;
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
tx.send(send_json("not valid json")).await.unwrap();
|
||||
|
||||
let msg = read_text(&mut rx).await;
|
||||
assert_eq!(msg["error"], "Invalid message format");
|
||||
assert!(msg["expected"].is_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t3_3_missing_fields_returns_error() {
|
||||
let app = start_app().await;
|
||||
let token = sign_token(&app, "user1");
|
||||
|
||||
let (mut tx, mut rx) = connect_bearer(app.addr, &token).await;
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
tx.send(send_json(r#"{"foo": "bar"}"#)).await.unwrap();
|
||||
|
||||
let msg = read_text(&mut rx).await;
|
||||
assert_eq!(msg["error"], "Invalid message format");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// T4 — Event broadcast and unicast
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn t4_1_broadcast_reaches_all_clients() {
|
||||
let app = start_app().await;
|
||||
let token = sign_token(&app, "user1");
|
||||
|
||||
let (_, mut rx1) = connect_bearer(app.addr, &token).await;
|
||||
let (_, mut rx2) = connect_bearer(app.addr, &token).await;
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
assert_eq!(ws_manager(&app).client_count(), 2);
|
||||
|
||||
let event = WebSocketMessage::new("test-broadcast", json!({"seq": 1}));
|
||||
ws_manager(&app).broadcast_all(event);
|
||||
|
||||
let msg1 = read_text(&mut rx1).await;
|
||||
let msg2 = read_text(&mut rx2).await;
|
||||
|
||||
assert_eq!(msg1["name"], "test-broadcast");
|
||||
assert_eq!(msg2["name"], "test-broadcast");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t4_2_unicast_reaches_only_target() {
|
||||
use nomifun_realtime::ConnectionId;
|
||||
|
||||
let app = start_app().await;
|
||||
let token = sign_token(&app, "user1");
|
||||
|
||||
let (_, mut rx1) = connect_bearer(app.addr, &token).await;
|
||||
let (_, mut rx2) = connect_bearer(app.addr, &token).await;
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
assert_eq!(ws_manager(&app).client_count(), 2);
|
||||
|
||||
let first_conn_id = ConnectionId(1);
|
||||
let msg = WebSocketMessage::new("unicast-test", json!({"target": true}));
|
||||
ws_manager(&app).send_to(first_conn_id, msg);
|
||||
|
||||
let received = read_text(&mut rx1).await;
|
||||
assert_eq!(received["name"], "unicast-test");
|
||||
|
||||
let timeout_result = tokio::time::timeout(Duration::from_millis(200), rx2.next()).await;
|
||||
assert!(timeout_result.is_err(), "rx2 should not receive the unicast");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t4_3_broadcast_after_disconnect_no_error() {
|
||||
let app = start_app().await;
|
||||
let token = sign_token(&app, "user1");
|
||||
|
||||
let (mut tx1, _rx1) = connect_bearer(app.addr, &token).await;
|
||||
let (_, mut rx2) = connect_bearer(app.addr, &token).await;
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
assert_eq!(ws_manager(&app).client_count(), 2);
|
||||
|
||||
// Disconnect client 1
|
||||
tx1.send(tungstenite::Message::Close(None)).await.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
assert_eq!(ws_manager(&app).client_count(), 1);
|
||||
|
||||
// Broadcast — should not error even though client 1 is gone
|
||||
let event = WebSocketMessage::new("after-disconnect", json!({}));
|
||||
ws_manager(&app).broadcast_all(event);
|
||||
|
||||
let msg = read_text(&mut rx2).await;
|
||||
assert_eq!(msg["name"], "after-disconnect");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// T5 — Built-in message handling
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn t5_1_pong_does_not_generate_response() {
|
||||
let app = start_app().await;
|
||||
let token = sign_token(&app, "user1");
|
||||
|
||||
let (mut tx, mut rx) = connect_bearer(app.addr, &token).await;
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
let pong = json!({"name": "pong", "data": {}});
|
||||
tx.send(send_json(&pong.to_string())).await.unwrap();
|
||||
|
||||
let timeout_result = tokio::time::timeout(Duration::from_millis(200), rx.next()).await;
|
||||
assert!(timeout_result.is_err(), "pong should not generate a response");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t5_2_subscribe_show_open_file_mode() {
|
||||
let app = start_app().await;
|
||||
let token = sign_token(&app, "user1");
|
||||
|
||||
let (mut tx, mut rx) = connect_bearer(app.addr, &token).await;
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
let payload = json!({
|
||||
"name": "subscribe-show-open",
|
||||
"data": {"id": "req-file", "data": {"properties": ["openFile"]}}
|
||||
});
|
||||
tx.send(send_json(&payload.to_string())).await.unwrap();
|
||||
|
||||
let msg = read_text(&mut rx).await;
|
||||
assert_eq!(msg["name"], "show-open-request");
|
||||
assert_eq!(msg["data"]["id"], "req-file");
|
||||
assert_eq!(msg["data"]["isFileMode"], true);
|
||||
assert_eq!(msg["data"]["properties"], json!(["openFile"]));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t5_3_subscribe_show_open_directory_mode() {
|
||||
let app = start_app().await;
|
||||
let token = sign_token(&app, "user1");
|
||||
|
||||
let (mut tx, mut rx) = connect_bearer(app.addr, &token).await;
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
let payload = json!({
|
||||
"name": "subscribe-show-open",
|
||||
"data": {"id": "req-dir", "data": {"properties": ["openDirectory"]}}
|
||||
});
|
||||
tx.send(send_json(&payload.to_string())).await.unwrap();
|
||||
|
||||
let msg = read_text(&mut rx).await;
|
||||
assert_eq!(msg["name"], "show-open-request");
|
||||
assert_eq!(msg["data"]["id"], "req-dir");
|
||||
assert_eq!(msg["data"]["isFileMode"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t5_4_subscribe_show_open_mixed_mode() {
|
||||
let app = start_app().await;
|
||||
let token = sign_token(&app, "user1");
|
||||
|
||||
let (mut tx, mut rx) = connect_bearer(app.addr, &token).await;
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
let payload = json!({
|
||||
"name": "subscribe-show-open",
|
||||
"data": {"id": "req-mixed", "data": {"properties": ["openFile", "openDirectory"]}}
|
||||
});
|
||||
tx.send(send_json(&payload.to_string())).await.unwrap();
|
||||
|
||||
let msg = read_text(&mut rx).await;
|
||||
assert_eq!(msg["name"], "show-open-request");
|
||||
assert_eq!(msg["data"]["id"], "req-mixed");
|
||||
assert_eq!(msg["data"]["isFileMode"], false);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// T6 — Connection close
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn t6_1_client_close_removes_from_manager() {
|
||||
let app = start_app().await;
|
||||
let token = sign_token(&app, "user1");
|
||||
|
||||
let (mut tx, _rx) = connect_bearer(app.addr, &token).await;
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
assert_eq!(ws_manager(&app).client_count(), 1);
|
||||
|
||||
tx.send(tungstenite::Message::Close(None)).await.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
assert_eq!(ws_manager(&app).client_count(), 0);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// T7 — Concurrent connections
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn t7_1_multiple_concurrent_connections() {
|
||||
let app = start_app().await;
|
||||
let token = sign_token(&app, "user1");
|
||||
|
||||
let mut handles = Vec::new();
|
||||
for _ in 0..10 {
|
||||
let addr = app.addr;
|
||||
let tok = token.clone();
|
||||
handles.push(tokio::spawn(async move { connect_bearer(addr, &tok).await }));
|
||||
}
|
||||
|
||||
let mut connections = Vec::new();
|
||||
for h in handles {
|
||||
connections.push(h.await.unwrap());
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
assert_eq!(ws_manager(&app).client_count(), 10);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// T7.2 — Blacklisted token rejected
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn t7_2_blacklisted_token_rejected() {
|
||||
let app = start_app().await;
|
||||
let token = sign_token(&app, "user1");
|
||||
|
||||
// Blacklist the token
|
||||
app.services.jwt_service.blacklist_token(&token);
|
||||
|
||||
let (_, mut rx) = connect_bearer(app.addr, &token).await;
|
||||
|
||||
let msg = read_text(&mut rx).await;
|
||||
assert_eq!(msg["name"], "auth-expired");
|
||||
|
||||
let code = read_close(&mut rx).await;
|
||||
assert_eq!(code, Some(1008));
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
//! Smoke test that drives the REAL desktop WebUI/LAN serving path end-to-end
|
||||
//! (`DesktopServer::start` → `start_lan`) against a throwaway data dir, so the
|
||||
//! actual failure cause of "enable WebUI" surfaces deterministically instead of
|
||||
//! being guessed at. Prints the resolved status (port, LAN IP, URL, error).
|
||||
|
||||
fn local_http_client() -> reqwest::Client {
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.build()
|
||||
.expect("local reqwest client should build")
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn webui_lan_start_smoke() {
|
||||
use clap::Parser as _;
|
||||
|
||||
// Isolated data dir so we never touch a running instance's state / lock.
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
// Isolate the data dir via --data-dir below (NOT a process-global env var):
|
||||
// these tests run in parallel, so a shared set_var("NOMIFUN_DATA_DIR") would
|
||||
// race and two backends could resolve to the SAME dir → "data directory
|
||||
// already in use by another running NomiFun backend".
|
||||
let data_dir = tmp.path().to_string_lossy().into_owned();
|
||||
let spa_dir = tmp.path().join("spa");
|
||||
std::fs::create_dir_all(&spa_dir).unwrap();
|
||||
std::fs::write(
|
||||
spa_dir.join("index.html"),
|
||||
"<!doctype html><title>Nomi</title>",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let cli = nomifun_app::cli::Cli::parse_from(["nomifun-desktop-test", "--data-dir", &data_dir]);
|
||||
let merged_path = std::env::var("PATH").unwrap_or_default();
|
||||
|
||||
let started = nomifun_app::DesktopServer::start(&cli, &merged_path, Some(spa_dir), None).await;
|
||||
let (server, _keep) = match started {
|
||||
Ok(pair) => pair,
|
||||
Err(e) => panic!("DesktopServer::start failed: {e:#}"),
|
||||
};
|
||||
|
||||
eprintln!("== loopback_port = {}", server.loopback_port());
|
||||
|
||||
let status = server.start_lan().await;
|
||||
eprintln!("== start_lan status = {status:?}");
|
||||
|
||||
server.stop_lan().await;
|
||||
|
||||
assert!(
|
||||
status.running,
|
||||
"start_lan did NOT run — error = {:?}",
|
||||
status.error
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn webui_lan_spa_deep_link_serves_app_shell() {
|
||||
use clap::Parser as _;
|
||||
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let data_dir = tmp.path().to_string_lossy().into_owned();
|
||||
let spa_dir = tmp.path().join("spa");
|
||||
std::fs::create_dir_all(&spa_dir).unwrap();
|
||||
std::fs::write(
|
||||
spa_dir.join("index.html"),
|
||||
"<!doctype html><title>Nomi deep link</title>",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let cli = nomifun_app::cli::Cli::parse_from(["nomifun-desktop-test", "--data-dir", &data_dir]);
|
||||
let merged_path = std::env::var("PATH").unwrap_or_default();
|
||||
|
||||
let (server, _keep) =
|
||||
nomifun_app::DesktopServer::start(&cli, &merged_path, Some(spa_dir), None)
|
||||
.await
|
||||
.expect("DesktopServer::start failed");
|
||||
|
||||
let status = server.start_lan().await;
|
||||
assert!(status.running, "start_lan failed: {:?}", status.error);
|
||||
|
||||
let response = local_http_client()
|
||||
.get(format!(
|
||||
"http://127.0.0.1:{}/open-capabilities",
|
||||
status.port
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.expect("request to LAN listener failed");
|
||||
let response_status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
|
||||
server.stop_lan().await;
|
||||
|
||||
assert_eq!(response_status, reqwest::StatusCode::OK);
|
||||
assert!(
|
||||
body.contains("Nomi deep link"),
|
||||
"SPA deep link should serve index.html, got status={response_status}; body={body}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A LAN listener with only `/qr-login` + `/api/auth/qr-login` but no SPA shell
|
||||
/// reproduces the phone symptom: the QR page can say "Login successful", then
|
||||
/// the browser navigates to `/` and receives an HTTP failure. Refuse that
|
||||
/// partial state at start-up so the desktop UI reports a real WebUI start error
|
||||
/// instead of handing users a broken QR flow.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn webui_lan_without_app_shell_fails_instead_of_serving_partial_qr_flow() {
|
||||
use clap::Parser as _;
|
||||
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let data_dir = tmp.path().to_string_lossy().into_owned();
|
||||
let cli = nomifun_app::cli::Cli::parse_from(["nomifun-desktop-test", "--data-dir", &data_dir]);
|
||||
let merged_path = std::env::var("PATH").unwrap_or_default();
|
||||
|
||||
let (server, _keep) = nomifun_app::DesktopServer::start(&cli, &merged_path, None, None)
|
||||
.await
|
||||
.expect("DesktopServer::start failed");
|
||||
|
||||
let status = server.start_lan().await;
|
||||
|
||||
assert!(
|
||||
!status.running,
|
||||
"LAN WebUI must not start without an app shell"
|
||||
);
|
||||
assert!(
|
||||
status
|
||||
.error
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("WebUI app shell"),
|
||||
"missing app shell error should be actionable, got {:?}",
|
||||
status.error
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression guard for the dev bug "saved figure image is broken + desktop
|
||||
/// companion renders blank". Native `<img>` / `new Image()` loads (figure
|
||||
/// thumbnails, the companion mesh texture) cannot present the local-trust
|
||||
/// header, so under `TrustLocalToken` the figure-image GET MUST be auth-exempt —
|
||||
/// while listing/creation stay authenticated. Boots the real desktop backend and
|
||||
/// hits its loopback port with NO trust header, exactly like a native image load.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn figure_image_get_is_public_but_listing_stays_authenticated() {
|
||||
use clap::Parser as _;
|
||||
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
// Isolate the data dir via --data-dir below (NOT a process-global env var):
|
||||
// these tests run in parallel, so a shared set_var("NOMIFUN_DATA_DIR") would
|
||||
// race and two backends could resolve to the SAME dir → "data directory
|
||||
// already in use by another running NomiFun backend".
|
||||
let data_dir = tmp.path().to_string_lossy().into_owned();
|
||||
|
||||
let cli = nomifun_app::cli::Cli::parse_from(["nomifun-desktop-test", "--data-dir", &data_dir]);
|
||||
let merged_path = std::env::var("PATH").unwrap_or_default();
|
||||
let (server, _keep) = nomifun_app::DesktopServer::start(&cli, &merged_path, None, None)
|
||||
.await
|
||||
.expect("DesktopServer::start failed");
|
||||
|
||||
let base = format!("http://127.0.0.1:{}", server.loopback_port());
|
||||
let client = local_http_client();
|
||||
|
||||
// Figure-image GET with NO trust header (what a native <img> sends): must NOT
|
||||
// be auth-rejected, AND must not 500. Under `TrustLocalToken` an untrusted
|
||||
// request gets no injected `CurrentUser`, so the handler must not depend on
|
||||
// that extension — an unknown id yields 404, a real one 200, never 401/403/500.
|
||||
let img = client
|
||||
.get(format!(
|
||||
"{base}/api/companion/figures/figure_nonexistent/image"
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.expect("figure image request failed");
|
||||
assert_eq!(
|
||||
img.status(),
|
||||
reqwest::StatusCode::NOT_FOUND,
|
||||
"figure-image GET for an unknown id must be a clean 404 (auth-exempt, no \
|
||||
CurrentUser dependency); got {} — a 401/403 means the route is still \
|
||||
authenticated, a 500 means the handler still extracts Extension<CurrentUser>",
|
||||
img.status()
|
||||
);
|
||||
|
||||
// The figures listing must STILL require auth — no trust header → rejected.
|
||||
let list = client
|
||||
.get(format!("{base}/api/companion/figures"))
|
||||
.send()
|
||||
.await
|
||||
.expect("figures listing request failed");
|
||||
assert!(
|
||||
list.status() == reqwest::StatusCode::UNAUTHORIZED
|
||||
|| list.status() == reqwest::StatusCode::FORBIDDEN,
|
||||
"figures listing must stay authenticated, got {}",
|
||||
list.status()
|
||||
);
|
||||
}
|
||||
|
||||
/// In DEV the LAN listener must serve the SAME live frontend the desktop webview
|
||||
/// loads — proxied to the vite dev server — NOT a stale bundled `ui/dist`. This
|
||||
/// stands up a mock "vite" server, points `DesktopServer` at it, enables LAN,
|
||||
/// and asserts a request to the LAN port is proxied through to the live content.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn webui_lan_dev_proxy_serves_live_frontend() {
|
||||
use clap::Parser as _;
|
||||
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
// Isolate the data dir via --data-dir below (NOT a process-global env var):
|
||||
// these tests run in parallel, so a shared set_var("NOMIFUN_DATA_DIR") would
|
||||
// race and two backends could resolve to the SAME dir → "data directory
|
||||
// already in use by another running NomiFun backend".
|
||||
let data_dir = tmp.path().to_string_lossy().into_owned();
|
||||
|
||||
// Mock vite dev server: returns a recognizable marker for any path.
|
||||
let mock = tokio::net::TcpListener::bind(("127.0.0.1", 0))
|
||||
.await
|
||||
.unwrap();
|
||||
let mock_port = mock.local_addr().unwrap().port();
|
||||
tokio::spawn(async move {
|
||||
let app = axum::Router::new().fallback(|| async { "LIVE_VITE_INDEX_MARKER" });
|
||||
let _ = axum::serve(mock, app).await;
|
||||
});
|
||||
|
||||
let cli = nomifun_app::cli::Cli::parse_from(["nomifun-desktop-test", "--data-dir", &data_dir]);
|
||||
let merged_path = std::env::var("PATH").unwrap_or_default();
|
||||
let dev_url = format!("http://127.0.0.1:{mock_port}");
|
||||
|
||||
let (server, _keep) =
|
||||
nomifun_app::DesktopServer::start(&cli, &merged_path, None, Some(dev_url))
|
||||
.await
|
||||
.expect("DesktopServer::start failed");
|
||||
|
||||
let status = server.start_lan().await;
|
||||
assert!(status.running, "start_lan failed: {:?}", status.error);
|
||||
|
||||
// A request to the LAN listener's SPA path must be proxied to the mock vite.
|
||||
let url = format!("http://127.0.0.1:{}/some/spa/route", status.port);
|
||||
let response = local_http_client()
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.expect("request to LAN listener failed");
|
||||
let response_status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
eprintln!("== proxied status = {response_status}; body = {body}");
|
||||
assert!(
|
||||
body.contains("LIVE_VITE_INDEX_MARKER"),
|
||||
"LAN listener did not proxy to the dev frontend; status={response_status}; got: {body}"
|
||||
);
|
||||
|
||||
server.stop_lan().await;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Integration tests verifying that --work-dir is used for conversation workspace creation.
|
||||
|
||||
use nomifun_api_types::CreateConversationRequest;
|
||||
use nomifun_app::{AppConfig, AppServices, build_conversation_state};
|
||||
use nomifun_common::AgentType;
|
||||
|
||||
#[tokio::test]
|
||||
async fn conversation_workspace_uses_work_dir() {
|
||||
let data_dir = tempfile::TempDir::new().unwrap();
|
||||
let work_dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
let db = nomifun_db::init_database_memory().await.unwrap();
|
||||
let config = AppConfig {
|
||||
data_dir: data_dir.path().to_path_buf(),
|
||||
work_dir: work_dir.path().to_path_buf(),
|
||||
auth_policy: nomifun_app::AuthPolicy::NoAuth,
|
||||
..Default::default()
|
||||
};
|
||||
let services = AppServices::from_config(db, &config).await.unwrap();
|
||||
let state = build_conversation_state(&services, None);
|
||||
|
||||
let request = CreateConversationRequest {
|
||||
r#type: AgentType::Acp,
|
||||
name: Some("test".to_string()),
|
||||
model: None,
|
||||
source: None,
|
||||
channel_chat_id: None,
|
||||
extra: serde_json::json!({}),
|
||||
};
|
||||
let response = state.service.create("system_default_user", request).await.unwrap();
|
||||
|
||||
let workspace = response.extra.get("workspace").and_then(|v| v.as_str()).unwrap();
|
||||
assert!(
|
||||
workspace.starts_with(work_dir.path().to_str().unwrap()),
|
||||
"workspace should be under work_dir, got: {workspace}"
|
||||
);
|
||||
assert!(
|
||||
!workspace.starts_with(data_dir.path().to_str().unwrap()),
|
||||
"workspace should NOT be under data_dir, got: {workspace}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_specified_workspace_is_not_overridden() {
|
||||
let data_dir = tempfile::TempDir::new().unwrap();
|
||||
let work_dir = tempfile::TempDir::new().unwrap();
|
||||
let custom_workspace = tempfile::TempDir::new().unwrap();
|
||||
|
||||
let db = nomifun_db::init_database_memory().await.unwrap();
|
||||
let config = AppConfig {
|
||||
data_dir: data_dir.path().to_path_buf(),
|
||||
work_dir: work_dir.path().to_path_buf(),
|
||||
auth_policy: nomifun_app::AuthPolicy::NoAuth,
|
||||
..Default::default()
|
||||
};
|
||||
let services = AppServices::from_config(db, &config).await.unwrap();
|
||||
let state = build_conversation_state(&services, None);
|
||||
|
||||
let request = CreateConversationRequest {
|
||||
r#type: AgentType::Acp,
|
||||
name: Some("test".to_string()),
|
||||
model: None,
|
||||
source: None,
|
||||
channel_chat_id: None,
|
||||
extra: serde_json::json!({
|
||||
"workspace": custom_workspace.path().to_str().unwrap()
|
||||
}),
|
||||
};
|
||||
let response = state.service.create("system_default_user", request).await.unwrap();
|
||||
|
||||
let workspace = response.extra.get("workspace").and_then(|v| v.as_str()).unwrap();
|
||||
assert!(
|
||||
workspace.starts_with(custom_workspace.path().to_str().unwrap()),
|
||||
"workspace should use user-specified path, got: {workspace}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_defaults_to_data_dir_when_work_dir_equals_data_dir() {
|
||||
let data_dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
let db = nomifun_db::init_database_memory().await.unwrap();
|
||||
let config = AppConfig {
|
||||
data_dir: data_dir.path().to_path_buf(),
|
||||
work_dir: data_dir.path().to_path_buf(),
|
||||
auth_policy: nomifun_app::AuthPolicy::NoAuth,
|
||||
..Default::default()
|
||||
};
|
||||
let services = AppServices::from_config(db, &config).await.unwrap();
|
||||
let state = build_conversation_state(&services, None);
|
||||
|
||||
let request = CreateConversationRequest {
|
||||
r#type: AgentType::Acp,
|
||||
name: Some("test".to_string()),
|
||||
model: None,
|
||||
source: None,
|
||||
channel_chat_id: None,
|
||||
extra: serde_json::json!({}),
|
||||
};
|
||||
let response = state.service.create("system_default_user", request).await.unwrap();
|
||||
|
||||
let workspace = response.extra.get("workspace").and_then(|v| v.as_str()).unwrap();
|
||||
assert!(
|
||||
workspace.starts_with(data_dir.path().to_str().unwrap()),
|
||||
"workspace should be under data_dir when work_dir == data_dir, got: {workspace}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user