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

304 lines
9.5 KiB
Rust

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