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

485 lines
16 KiB
Rust

//! 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.
// ===========================================================================