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

620 lines
22 KiB
Rust

//! 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());
}