物理正确性校验体系(common/conv_check.rs +494 行) - 新增 5 类硬门槛:能量守恒(.6)、温度结构(.7)、emflux 积分校验(.emflux,含全 NaN 判失败)、假收敛排查(itek 轨迹首末比)、b 因子合理性(.bfac) - runner 在 TLUSTY 阶段结束后执行全部校验,任一失败判 final_converged=false - GridConfig 新增 8 个可配阈值,经 scheduler→executor→runner 全链路透传 输入文件配置结构化重构(config.rs +1453 行) - TlustyInput 拆为 dot5/nst 分层结构,字段名严格映射 tlusty208.f READ 语句;SynspecInput 重构为 9 个 Fort55Line 子结构体 - 移除 ChainStep.metals 字段,元素集改由 dot5.atoms/ions 显式声明(gen_input5/nst_writer 同步重写为三源融合 / 分层覆盖) - fort.55 修复行结构 bug:补全分子表行(7→9 行),IDSTD 50→0 错位修正(影响全部光谱线强归一化,需重算 SYNSPEC 阶段) conv 诊断 DB 化与阶段归因修复(server) - 单点详情 conv 面板从磁盘 conv.json 改读 DB grid_points.summary_json;grid_points 新增 summary_json/last_elapsed_sec 两列(旧库幂等 ALTER) - record_task_report 阶段归因列加 CASE 守卫 + clear_synspec 对称处理,修复 synspec-only/TLUSTY-only 重跑污染统计 - 新增 summary_merge.rs 点级增量合并,避免重跑覆盖诊断字段 收敛性 ORELAX 修复与 seed_chain 可配(sdB_cno.yaml + node) - nl 阶段加 orelax=0.5、seed_nc 加 orelax=0.3,阻尼中温区 relc 振荡发散 - seed_chain 块可配,executor 优先采用用户配置而非内置默认链 导入工具下线 - 删除 import_results 客户端工具及 Windows 推送脚本;移除 /admin/import_seed 端点 - 改为服务端临时 migrate_conv 端点(扫 conv.json 增量合并入库,迁移后可删) 文档与分析 - 新增 1305 失败点根因分析、fort.14 全 NaN 物理含义分析两份深度文档 - spectrum_correctness_analysis 两次修订标注已修复项;fetch_results.sh 修 trap RETURN 的 set -u 报错
3078 lines
108 KiB
Rust
3078 lines
108 KiB
Rust
use axum::{
|
||
body::Body,
|
||
http::{Request, StatusCode},
|
||
};
|
||
use common::models::{GridPointParams, ModelSummary};
|
||
use mq::sqlite_queue::SqliteTaskQueue;
|
||
use server::{api::AppState, db::Database, scheduler::GridScheduler};
|
||
use std::sync::Arc;
|
||
use tower::ServiceExt; // for oneshot
|
||
|
||
/// 测试辅助:把一个已 upsert 的点标记为导入收敛(写 summary_json + status=completed)。
|
||
/// 等价旧 mark_grid_point_imported。
|
||
async fn mark_imported(db: &Database, name: &str, wf: &str, params: &GridPointParams, method: &str) {
|
||
let summary = ModelSummary {
|
||
name: name.to_string(),
|
||
params: params.clone(),
|
||
stages: Vec::new(),
|
||
result_valid: true,
|
||
final_max_relc: Some(0.001),
|
||
final_chmax: Some(0.001),
|
||
seed: None,
|
||
atmosphere_has_nan: false,
|
||
synspec_rc: None,
|
||
synspec_error: None,
|
||
synspec_sec: None,
|
||
elapsed_sec: 0.0,
|
||
energy_check: None,
|
||
temp_check: None,
|
||
emflux_check: None,
|
||
bfac_check: None,
|
||
note: None,
|
||
};
|
||
db.upsert_point_summary(name, wf, &summary, method)
|
||
.await
|
||
.unwrap();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_server_api_flow() {
|
||
let temp_dir = tempfile::tempdir().unwrap();
|
||
let db_path = temp_dir.path().join("api_db.db");
|
||
let queue_db_path = temp_dir.path().join("api_queue.db");
|
||
let seeds_dir = temp_dir.path().join("results");
|
||
std::fs::create_dir_all(&seeds_dir).unwrap();
|
||
|
||
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
|
||
let queue = Arc::new(
|
||
SqliteTaskQueue::new(&queue_db_path.to_string_lossy())
|
||
.await
|
||
.unwrap(),
|
||
);
|
||
let scheduler = Arc::new(GridScheduler::new(db.clone(), queue.clone()));
|
||
|
||
let state = AppState {
|
||
db,
|
||
queue,
|
||
scheduler,
|
||
seeds_dir: seeds_dir.to_string_lossy().to_string(),
|
||
rate_limiter: server::api::rate_limit::RateLimiter::new(
|
||
5,
|
||
std::time::Duration::from_secs(300),
|
||
),
|
||
admin_token: None,
|
||
auth_disabled: false,
|
||
admin_sessions: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
|
||
};
|
||
|
||
let app = axum::Router::new()
|
||
.route(
|
||
"/api/node/register",
|
||
axum::routing::post(server::api::node::register_node),
|
||
)
|
||
.route(
|
||
"/api/node/heartbeat",
|
||
axum::routing::post(server::api::node::heartbeat_node),
|
||
)
|
||
.route(
|
||
"/api/task/claim",
|
||
axum::routing::post(server::api::task::claim_task),
|
||
)
|
||
.route(
|
||
"/api/status",
|
||
axum::routing::get(server::api::status::get_status),
|
||
)
|
||
.route(
|
||
"/api/workflows",
|
||
axum::routing::get(server::api::workflow::list_workflows)
|
||
.post(server::api::workflow::save_workflow),
|
||
)
|
||
.with_state(state);
|
||
|
||
// 1. Check status API
|
||
let response = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri("/api/status")
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
|
||
assert_eq!(response.status(), StatusCode::OK);
|
||
|
||
// 2. Register node API
|
||
let reg_body = serde_json::json!({
|
||
"node_id": "test-node-api",
|
||
"max_slots": 8
|
||
});
|
||
let response = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/node/register")
|
||
.header("content-type", "application/json")
|
||
.body(Body::from(serde_json::to_vec(®_body).unwrap()))
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
|
||
assert_eq!(response.status(), StatusCode::OK);
|
||
|
||
// 3. Save Workflow API
|
||
let wf_body = serde_json::json!({
|
||
"name": "test_api_wf",
|
||
"description": "Test Workflow Description",
|
||
"config_yaml": "grid:\n teff: [35000]\n logg: [5.5]\n loghe: [-1]\n logc: [-2]\n logn: [-2]\n logo: [-2]"
|
||
});
|
||
let response = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/workflows")
|
||
.header("content-type", "application/json")
|
||
.body(Body::from(serde_json::to_vec(&wf_body).unwrap()))
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
|
||
assert_eq!(response.status(), StatusCode::OK);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_auth_middleware_scope_and_running_status() {
|
||
let temp_dir = tempfile::tempdir().unwrap();
|
||
let db_path = temp_dir.path().join("auth_db.db");
|
||
let queue_db_path = temp_dir.path().join("auth_queue.db");
|
||
let seeds_dir = temp_dir.path().join("results");
|
||
std::fs::create_dir_all(&seeds_dir).unwrap();
|
||
|
||
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
|
||
let queue = Arc::new(
|
||
SqliteTaskQueue::new(&queue_db_path.to_string_lossy())
|
||
.await
|
||
.unwrap(),
|
||
);
|
||
let scheduler = Arc::new(GridScheduler::new(db.clone(), queue.clone()));
|
||
|
||
let state = AppState {
|
||
db: db.clone(),
|
||
queue: queue.clone(),
|
||
scheduler,
|
||
seeds_dir: seeds_dir.to_string_lossy().to_string(),
|
||
rate_limiter: server::api::rate_limit::RateLimiter::new(
|
||
5,
|
||
std::time::Duration::from_secs(300),
|
||
),
|
||
admin_token: Some("secret_token_123".to_string()),
|
||
auth_disabled: false,
|
||
admin_sessions: std::sync::Arc::new(tokio::sync::RwLock::new(
|
||
std::collections::HashMap::new(),
|
||
)),
|
||
};
|
||
|
||
let api_router = axum::Router::new().route(
|
||
"/status",
|
||
axum::routing::get(server::api::status::get_status),
|
||
);
|
||
|
||
let auth_layer =
|
||
axum::middleware::from_fn_with_state(state.clone(), server::api::auth_middleware);
|
||
let api_router = api_router.layer(auth_layer);
|
||
|
||
let app = axum::Router::new()
|
||
.nest("/api", api_router)
|
||
.with_state(state);
|
||
|
||
// Unauthenticated API request -> 401 Unauthorized
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri("/api/status")
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
|
||
|
||
// Authenticated API request -> 200 OK
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri("/api/status")
|
||
.header("authorization", "Bearer secret_token_123")
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::OK);
|
||
|
||
// Test mark_grid_point_running
|
||
let params = common::models::GridPointParams {
|
||
teff: 35000.0.into(),
|
||
logg: 5.5.into(),
|
||
loghe: (-1.0).into(),
|
||
logc: (-2.0).into(),
|
||
logn: (-2.0).into(),
|
||
logo: (-2.0).into(),
|
||
};
|
||
db.upsert_grid_point(¶ms, 0, "test_wf").await.unwrap();
|
||
db.mark_grid_point_running(¶ms.model_name(), "test_wf")
|
||
.await
|
||
.unwrap();
|
||
|
||
let stats = db.get_grid_summary_stats(None).await.unwrap();
|
||
assert_eq!(stats["running"], 1);
|
||
}
|
||
|
||
/// L2 鉴权核心流程测试:
|
||
/// 注册节点 → 颁发专属 token → 反查校验 → 重发后旧 token 失效(覆盖式,无需独立吊销)。
|
||
#[tokio::test]
|
||
async fn test_l2_node_token_issue_reissue_flow() {
|
||
let temp_dir = tempfile::tempdir().unwrap();
|
||
let db_path = temp_dir.path().join("l2_db.db");
|
||
|
||
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
|
||
|
||
// 1. 注册节点
|
||
let reg = common::models::NodeRegisterRequest {
|
||
node_id: "node-l2-test".to_string(),
|
||
max_slots: 4,
|
||
};
|
||
let (_, _, secret) = db.register_node(®).await.unwrap();
|
||
|
||
// 2. 颁发专属 token,返回明文
|
||
let token = db.issue_node_token("node-l2-test").await.unwrap();
|
||
assert!(!token.is_empty());
|
||
|
||
// 2b. 一次性取走暂存明文(take_pending_node_token):首次取到与颁发一致的明文,
|
||
// 再次取为 None(取走即焚)。验证 #4 简化为单一 UPDATE...RETURNING 后行为一致。
|
||
// H8:须提供注册时下发的 registration_secret。
|
||
let pending = db
|
||
.take_pending_node_token("node-l2-test", secret.as_deref())
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(pending.as_deref(), Some(token.as_str()));
|
||
let pending2 = db
|
||
.take_pending_node_token("node-l2-test", secret.as_deref())
|
||
.await
|
||
.unwrap();
|
||
assert!(
|
||
pending2.is_none(),
|
||
"取走即焚:第二次 take_pending 必须返回 None"
|
||
);
|
||
// 不存在的 node take 也应返回 None(不报错)
|
||
assert!(db
|
||
.take_pending_node_token("node-not-exist", None)
|
||
.await
|
||
.unwrap()
|
||
.is_none());
|
||
|
||
// 3. token 可反查到 node_id(此调用会把 token 写入内存缓存)
|
||
let found = db.find_node_by_token(&token).await;
|
||
assert_eq!(found.as_deref(), Some("node-l2-test"));
|
||
|
||
// 4. 错误 token 查不到
|
||
assert!(db.find_node_by_token("wrong-token").await.is_none());
|
||
|
||
// 5. 重发(reissue)后新 token 生效;旧 token 因 hash 被覆盖而立即失效。
|
||
// 这正是「重发取代吊销」的核心证据:无需独立 revoked 标记,覆盖 hash 即让旧 token 失效。
|
||
let token2 = db.issue_node_token("node-l2-test").await.unwrap();
|
||
assert_eq!(
|
||
db.find_node_by_token(&token2).await.as_deref(),
|
||
Some("node-l2-test")
|
||
);
|
||
// 旧 token 仍失效(已被覆盖)
|
||
assert!(db.find_node_by_token(&token).await.is_none());
|
||
|
||
// M2:registration_secret 被首次 take 轮换后,旧 secret 无法取走 reissue 产生的新 token。
|
||
// 首次 take(上文)已把 secret 轮换为无人知晓的新值,旧 secret(注册时下发的)立即失效。
|
||
let stolen = db
|
||
.take_pending_node_token("node-l2-test", secret.as_deref())
|
||
.await
|
||
.unwrap();
|
||
assert!(
|
||
stolen.is_none(),
|
||
"被轮换的旧 registration_secret 不得取走 reissue 后的新 token(M2 一次性凭据)"
|
||
);
|
||
}
|
||
|
||
/// 验证中间件对 node token 的端到端鉴权:
|
||
/// 用真实 node token 调 /node/heartbeat 应 200;重发(覆盖旧 token)后再调应 401。
|
||
#[tokio::test]
|
||
async fn test_middleware_node_role_with_token() {
|
||
let temp_dir = tempfile::tempdir().unwrap();
|
||
let db_path = temp_dir.path().join("l2_mw_db.db");
|
||
let queue_db_path = temp_dir.path().join("l2_mw_queue.db");
|
||
let seeds_dir = temp_dir.path().join("results");
|
||
std::fs::create_dir_all(&seeds_dir).unwrap();
|
||
|
||
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
|
||
let queue = Arc::new(
|
||
SqliteTaskQueue::new(&queue_db_path.to_string_lossy())
|
||
.await
|
||
.unwrap(),
|
||
);
|
||
let scheduler = Arc::new(GridScheduler::new(db.clone(), queue.clone()));
|
||
|
||
// 注册并颁发 token
|
||
let reg = common::models::NodeRegisterRequest {
|
||
node_id: "node-mw-test".to_string(),
|
||
max_slots: 2,
|
||
};
|
||
db.register_node(®).await.unwrap();
|
||
let token = db.issue_node_token("node-mw-test").await.unwrap();
|
||
|
||
let state = AppState {
|
||
db: db.clone(),
|
||
queue,
|
||
scheduler,
|
||
seeds_dir: seeds_dir.to_string_lossy().to_string(),
|
||
rate_limiter: server::api::rate_limit::RateLimiter::new(
|
||
5,
|
||
std::time::Duration::from_secs(300),
|
||
),
|
||
admin_token: None,
|
||
auth_disabled: false,
|
||
admin_sessions: std::sync::Arc::new(tokio::sync::RwLock::new(
|
||
std::collections::HashMap::new(),
|
||
)),
|
||
};
|
||
|
||
let api_router = axum::Router::new().route(
|
||
"/node/heartbeat",
|
||
axum::routing::post(server::api::node::heartbeat_node),
|
||
);
|
||
let auth_layer =
|
||
axum::middleware::from_fn_with_state(state.clone(), server::api::auth_middleware);
|
||
let api_router = api_router.layer(auth_layer);
|
||
let app = axum::Router::new()
|
||
.nest("/api", api_router)
|
||
.with_state(state);
|
||
|
||
// 用有效 node token 调 heartbeat → 200
|
||
let hb_body = serde_json::json!({
|
||
"node_id": "node-mw-test", "active_slots": 1, "cpu_usage": 10.0, "memory_usage": 20.0
|
||
});
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/node/heartbeat")
|
||
.header("authorization", format!("Bearer {}", token))
|
||
.header("content-type", "application/json")
|
||
.body(Body::from(serde_json::to_vec(&hb_body).unwrap()))
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::OK);
|
||
|
||
// 重发(reissue)后旧 token 经中间件 → 401:reissue 覆盖 token_hash,
|
||
// 旧 token 明文 hash 不再匹配,find_node_by_token 返回 None → 鉴权失败。
|
||
// 这是「重发取代吊销」的端到端证据(经真实中间件而非仅 DB 层)。
|
||
db.issue_node_token("node-mw-test").await.unwrap();
|
||
let res = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/node/heartbeat")
|
||
.header("authorization", format!("Bearer {}", token))
|
||
.header("content-type", "application/json")
|
||
.body(Body::from(serde_json::to_vec(&hb_body).unwrap()))
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
|
||
}
|
||
|
||
/// 管理 API(Admin)端到端测试:
|
||
/// 列出节点 → 重发 token → 鉴权(admin 放行,node/匿名 401)。
|
||
#[tokio::test]
|
||
async fn test_admin_node_management_api() {
|
||
let temp_dir = tempfile::tempdir().unwrap();
|
||
let db_path = temp_dir.path().join("admin_db.db");
|
||
let queue_db_path = temp_dir.path().join("admin_queue.db");
|
||
let seeds_dir = temp_dir.path().join("results");
|
||
std::fs::create_dir_all(&seeds_dir).unwrap();
|
||
|
||
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
|
||
let queue = Arc::new(
|
||
SqliteTaskQueue::new(&queue_db_path.to_string_lossy())
|
||
.await
|
||
.unwrap(),
|
||
);
|
||
let scheduler = Arc::new(GridScheduler::new(db.clone(), queue.clone()));
|
||
|
||
// 预置一个已注册并颁发 token 的节点
|
||
let reg = common::models::NodeRegisterRequest {
|
||
node_id: "node-admin-test".to_string(),
|
||
max_slots: 2,
|
||
};
|
||
db.register_node(®).await.unwrap();
|
||
let original_token = db.issue_node_token("node-admin-test").await.unwrap();
|
||
|
||
let state = AppState {
|
||
db: db.clone(),
|
||
queue,
|
||
scheduler,
|
||
seeds_dir: seeds_dir.to_string_lossy().to_string(),
|
||
rate_limiter: server::api::rate_limit::RateLimiter::new(
|
||
5,
|
||
std::time::Duration::from_secs(300),
|
||
),
|
||
admin_token: Some("admin-secret".to_string()),
|
||
auth_disabled: false,
|
||
admin_sessions: std::sync::Arc::new(tokio::sync::RwLock::new(
|
||
std::collections::HashMap::new(),
|
||
)),
|
||
};
|
||
|
||
let api_router = axum::Router::new()
|
||
.route(
|
||
"/admin/nodes",
|
||
axum::routing::get(server::api::admin::list_nodes),
|
||
)
|
||
.route(
|
||
"/admin/nodes/:node_id/reissue",
|
||
axum::routing::post(server::api::admin::reissue_node),
|
||
);
|
||
let auth_layer =
|
||
axum::middleware::from_fn_with_state(state.clone(), server::api::auth_middleware);
|
||
let app = axum::Router::new()
|
||
.nest("/api", api_router.layer(auth_layer))
|
||
.with_state(state);
|
||
|
||
// 1. 匿名访问 /admin/nodes → 401
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri("/api/admin/nodes")
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
|
||
|
||
// 2. admin 访问 /admin/nodes → 200,且能看到预置节点 token_status=active
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri("/api/admin/nodes")
|
||
.header("authorization", "Bearer admin-secret")
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::OK);
|
||
let body: serde_json::Value = serde_json::from_slice(
|
||
&axum::body::to_bytes(res.into_body(), usize::MAX)
|
||
.await
|
||
.unwrap(),
|
||
)
|
||
.unwrap();
|
||
let nodes = body["data"].as_array().expect("data 应为数组");
|
||
let target = nodes
|
||
.iter()
|
||
.find(|n| n["node_id"] == "node-admin-test")
|
||
.expect("应包含预置节点");
|
||
assert_eq!(target["token_status"], "active");
|
||
|
||
// 3. admin 重发 token → 200,返回新明文 token
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/admin/nodes/node-admin-test/reissue")
|
||
.header("authorization", "Bearer admin-secret")
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::OK);
|
||
let body: serde_json::Value = serde_json::from_slice(
|
||
&axum::body::to_bytes(res.into_body(), usize::MAX)
|
||
.await
|
||
.unwrap(),
|
||
)
|
||
.unwrap();
|
||
let new_token = body["node_token"].as_str().expect("应返回 node_token");
|
||
assert!(!new_token.is_empty());
|
||
assert_ne!(new_token, original_token);
|
||
// 新 token 可用
|
||
assert_eq!(
|
||
db.find_node_by_token(new_token).await.as_deref(),
|
||
Some("node-admin-test")
|
||
);
|
||
// 重发使旧 token 立即失效(hash 被覆盖)——这正是「重发取代吊销」的核心语义
|
||
assert!(
|
||
db.find_node_by_token(&original_token).await.is_none(),
|
||
"重发后旧 token 必须立即失效"
|
||
);
|
||
|
||
// 4. node token 不能访问 admin API → 401(即便持有有效 node token)
|
||
let res = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri("/api/admin/nodes")
|
||
.header("authorization", format!("Bearer {}", new_token))
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
|
||
}
|
||
|
||
/// S1 身份绑定测试:节点 A 用自己 token 冒充节点 B 发心跳 → 403;一致时 → 200。
|
||
#[tokio::test]
|
||
async fn test_node_heartbeat_node_id_binding() {
|
||
let temp_dir = tempfile::tempdir().unwrap();
|
||
let db_path = temp_dir.path().join("bind_db.db");
|
||
let queue_db_path = temp_dir.path().join("bind_queue.db");
|
||
let seeds_dir = temp_dir.path().join("results");
|
||
std::fs::create_dir_all(&seeds_dir).unwrap();
|
||
|
||
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
|
||
let queue = Arc::new(
|
||
SqliteTaskQueue::new(&queue_db_path.to_string_lossy())
|
||
.await
|
||
.unwrap(),
|
||
);
|
||
let scheduler = Arc::new(GridScheduler::new(db.clone(), queue));
|
||
|
||
// 预置节点 A,颁发 token
|
||
let reg = common::models::NodeRegisterRequest {
|
||
node_id: "node-A".to_string(),
|
||
max_slots: 2,
|
||
};
|
||
db.register_node(®).await.unwrap();
|
||
let token_a = db.issue_node_token("node-A").await.unwrap();
|
||
|
||
let state = AppState {
|
||
db: db.clone(),
|
||
queue: Arc::new(SqliteTaskQueue::new(":memory:").await.unwrap()),
|
||
scheduler,
|
||
seeds_dir: seeds_dir.to_string_lossy().to_string(),
|
||
rate_limiter: server::api::rate_limit::RateLimiter::new(
|
||
5,
|
||
std::time::Duration::from_secs(300),
|
||
),
|
||
admin_token: None,
|
||
auth_disabled: false,
|
||
admin_sessions: std::sync::Arc::new(tokio::sync::RwLock::new(
|
||
std::collections::HashMap::new(),
|
||
)),
|
||
};
|
||
let api_router = axum::Router::new().route(
|
||
"/node/heartbeat",
|
||
axum::routing::post(server::api::node::heartbeat_node),
|
||
);
|
||
let auth_layer =
|
||
axum::middleware::from_fn_with_state(state.clone(), server::api::auth_middleware);
|
||
let app = axum::Router::new()
|
||
.nest("/api", api_router.layer(auth_layer))
|
||
.with_state(state);
|
||
|
||
// A 用自己 token,但 body 声称 node_id=node-B(冒充)→ 403
|
||
let hb =
|
||
serde_json::json!({"node_id":"node-B","active_slots":1,"cpu_usage":0.0,"memory_usage":0.0});
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/node/heartbeat")
|
||
.header("authorization", format!("Bearer {}", token_a))
|
||
.header("content-type", "application/json")
|
||
.body(Body::from(serde_json::to_vec(&hb).unwrap()))
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::FORBIDDEN);
|
||
|
||
// A 用自己 token,body node_id=node-A(一致)→ 200
|
||
let hb_ok =
|
||
serde_json::json!({"node_id":"node-A","active_slots":1,"cpu_usage":0.0,"memory_usage":0.0});
|
||
let res = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/node/heartbeat")
|
||
.header("authorization", format!("Bearer {}", token_a))
|
||
.header("content-type", "application/json")
|
||
.body(Body::from(serde_json::to_vec(&hb_ok).unwrap()))
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::OK);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_admin_login_flow() {
|
||
let temp_dir = tempfile::tempdir().unwrap();
|
||
let db_path = temp_dir.path().join("login_db.db");
|
||
let queue_db_path = temp_dir.path().join("login_queue.db");
|
||
let seeds_dir = temp_dir.path().join("results");
|
||
std::fs::create_dir_all(&seeds_dir).unwrap();
|
||
|
||
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
|
||
let queue = Arc::new(
|
||
SqliteTaskQueue::new(&queue_db_path.to_string_lossy())
|
||
.await
|
||
.unwrap(),
|
||
);
|
||
let scheduler = Arc::new(GridScheduler::new(db.clone(), queue.clone()));
|
||
|
||
let admin_pass = "my_short_admin_password_123";
|
||
let state = AppState {
|
||
db,
|
||
queue,
|
||
scheduler,
|
||
seeds_dir: seeds_dir.to_string_lossy().to_string(),
|
||
rate_limiter: server::api::rate_limit::RateLimiter::new(
|
||
5,
|
||
std::time::Duration::from_secs(300),
|
||
),
|
||
admin_token: Some(admin_pass.to_string()),
|
||
auth_disabled: false,
|
||
admin_sessions: std::sync::Arc::new(tokio::sync::RwLock::new(
|
||
std::collections::HashMap::new(),
|
||
)),
|
||
};
|
||
|
||
let api_router = axum::Router::new()
|
||
.route("/login", axum::routing::post(server::api::auth::login))
|
||
.route(
|
||
"/auth/check",
|
||
axum::routing::get(server::api::auth::check_auth),
|
||
)
|
||
.layer(axum::middleware::from_fn_with_state(
|
||
state.clone(),
|
||
server::api::auth_middleware,
|
||
));
|
||
|
||
let app = axum::Router::new()
|
||
.nest("/api", api_router)
|
||
.with_state(state);
|
||
|
||
// 1. 密码错误 ➔ 401
|
||
let wrong_body = serde_json::json!({ "password": "wrong_password" });
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/login")
|
||
.header("content-type", "application/json")
|
||
.extension(axum::extract::ConnectInfo(std::net::SocketAddr::from((
|
||
[127, 0, 0, 1],
|
||
12345,
|
||
))))
|
||
.body(Body::from(serde_json::to_vec(&wrong_body).unwrap()))
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
|
||
|
||
// 2. 正确密码 ➔ 200 + token
|
||
let right_body = serde_json::json!({ "password": admin_pass });
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/login")
|
||
.header("content-type", "application/json")
|
||
.extension(axum::extract::ConnectInfo(std::net::SocketAddr::from((
|
||
[127, 0, 0, 1],
|
||
12345,
|
||
))))
|
||
.body(Body::from(serde_json::to_vec(&right_body).unwrap()))
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::OK);
|
||
let bytes = axum::body::to_bytes(res.into_body(), 1024 * 1024)
|
||
.await
|
||
.unwrap();
|
||
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||
assert_eq!(json["success"], true);
|
||
let token = json["token"].as_str().unwrap();
|
||
assert!(token.len() == 64);
|
||
|
||
// 3. 携带拿到的 Token 访问受保护的 /api/auth/check ➔ 200
|
||
let res = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("GET")
|
||
.uri("/api/auth/check")
|
||
.header("authorization", format!("Bearer {}", token))
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::OK);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_node_approval_workflow() {
|
||
let temp_dir = tempfile::tempdir().unwrap();
|
||
let db_path = temp_dir.path().join("appr_db.db");
|
||
let queue_db_path = temp_dir.path().join("appr_queue.db");
|
||
let seeds_dir = temp_dir.path().join("results");
|
||
std::fs::create_dir_all(&seeds_dir).unwrap();
|
||
|
||
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
|
||
let queue = Arc::new(
|
||
SqliteTaskQueue::new(&queue_db_path.to_string_lossy())
|
||
.await
|
||
.unwrap(),
|
||
);
|
||
let scheduler = Arc::new(GridScheduler::new(db.clone(), queue.clone()));
|
||
|
||
let admin_pass = "admin_approval_secret";
|
||
let state = AppState {
|
||
db,
|
||
queue,
|
||
scheduler,
|
||
seeds_dir: seeds_dir.to_string_lossy().to_string(),
|
||
rate_limiter: server::api::rate_limit::RateLimiter::new(
|
||
5,
|
||
std::time::Duration::from_secs(300),
|
||
),
|
||
admin_token: Some(admin_pass.to_string()),
|
||
auth_disabled: false,
|
||
admin_sessions: std::sync::Arc::new(tokio::sync::RwLock::new(
|
||
std::collections::HashMap::new(),
|
||
)),
|
||
};
|
||
|
||
let api_router = axum::Router::new()
|
||
.route(
|
||
"/node/register",
|
||
axum::routing::post(server::api::node::register_node),
|
||
)
|
||
.route(
|
||
"/node/check_status",
|
||
axum::routing::post(server::api::node::check_node_status),
|
||
)
|
||
.route(
|
||
"/node/heartbeat",
|
||
axum::routing::post(server::api::node::heartbeat_node),
|
||
)
|
||
.route(
|
||
"/admin/nodes/:node_id/approve",
|
||
axum::routing::post(server::api::admin::approve_node),
|
||
)
|
||
.layer(axum::middleware::from_fn_with_state(
|
||
state.clone(),
|
||
server::api::auth_middleware,
|
||
));
|
||
|
||
let app = axum::Router::new()
|
||
.nest("/api", api_router)
|
||
.with_state(state);
|
||
|
||
// 1. 新节点免凭据申请注册 ➔ 200 + status: pending_approval
|
||
let reg_body = serde_json::json!({
|
||
"node_id": "node-pending-01",
|
||
"max_slots": 4
|
||
});
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/node/register")
|
||
.header("content-type", "application/json")
|
||
.body(Body::from(serde_json::to_vec(®_body).unwrap()))
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::OK);
|
||
let bytes = axum::body::to_bytes(res.into_body(), 1024 * 1024)
|
||
.await
|
||
.unwrap();
|
||
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||
assert_eq!(json["status"], "pending_approval");
|
||
// H8:注册响应下发一次性 registration_secret,后续 check_status 取 token 须回传。
|
||
let reg_secret = json["registration_secret"].as_str().map(|s| s.to_string());
|
||
assert!(reg_secret.is_some(), "注册响应应包含 registration_secret");
|
||
|
||
// 2. Node 端轮询查状态 ➔ status: pending_approval
|
||
let check_body = serde_json::json!({
|
||
"node_id": "node-pending-01",
|
||
"registration_secret": reg_secret,
|
||
});
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/node/check_status")
|
||
.header("content-type", "application/json")
|
||
.body(Body::from(serde_json::to_vec(&check_body).unwrap()))
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::OK);
|
||
let bytes = axum::body::to_bytes(res.into_body(), 1024 * 1024)
|
||
.await
|
||
.unwrap();
|
||
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||
assert_eq!(json["status"], "pending_approval");
|
||
|
||
// 3. 管理员在 Dashboard 点击同意 ➔ 200
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/admin/nodes/node-pending-01/approve")
|
||
.header("authorization", format!("Bearer {}", admin_pass))
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::OK);
|
||
let bytes = axum::body::to_bytes(res.into_body(), 1024 * 1024)
|
||
.await
|
||
.unwrap();
|
||
println!("Step 3 body: {}", String::from_utf8_lossy(&bytes));
|
||
|
||
// 4. Node 端再次轮询查状态 ➔ status: approved + 获取 node_token
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/node/check_status")
|
||
.header("content-type", "application/json")
|
||
.body(Body::from(serde_json::to_vec(&check_body).unwrap()))
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::OK);
|
||
let bytes = axum::body::to_bytes(res.into_body(), 1024 * 1024)
|
||
.await
|
||
.unwrap();
|
||
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||
println!("Step 4 status returned: {:?}", json);
|
||
|
||
assert_eq!(json["status"], "approved");
|
||
let node_token = json["node_token"].as_str().unwrap();
|
||
|
||
// 5. Node 携带拿到到的专属 Token 发送心跳 ➔ 200
|
||
let hb_body = serde_json::json!({
|
||
"node_id": "node-pending-01",
|
||
"active_slots": 1,
|
||
"cpu_usage": 10.0,
|
||
"memory_usage": 20.0
|
||
});
|
||
let res = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/node/heartbeat")
|
||
.header("authorization", format!("Bearer {}", node_token))
|
||
.header("content-type", "application/json")
|
||
.body(Body::from(serde_json::to_vec(&hb_body).unwrap()))
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::OK);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_admin_sessions_capacity_limit() {
|
||
let temp_dir = tempfile::tempdir().unwrap();
|
||
let db_path = temp_dir.path().join("sess_db.db");
|
||
let queue_db_path = temp_dir.path().join("sess_queue.db");
|
||
let seeds_dir = temp_dir.path().join("results");
|
||
std::fs::create_dir_all(&seeds_dir).unwrap();
|
||
|
||
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
|
||
let queue = Arc::new(
|
||
SqliteTaskQueue::new(&queue_db_path.to_string_lossy())
|
||
.await
|
||
.unwrap(),
|
||
);
|
||
let scheduler = Arc::new(GridScheduler::new(db.clone(), queue.clone()));
|
||
|
||
let admin_pass = "admin_capacity_secret";
|
||
let state = AppState {
|
||
db,
|
||
queue,
|
||
scheduler,
|
||
seeds_dir: seeds_dir.to_string_lossy().to_string(),
|
||
rate_limiter: server::api::rate_limit::RateLimiter::new(
|
||
1000,
|
||
std::time::Duration::from_secs(300),
|
||
),
|
||
admin_token: Some(admin_pass.to_string()),
|
||
auth_disabled: false,
|
||
admin_sessions: std::sync::Arc::new(tokio::sync::RwLock::new(
|
||
std::collections::HashMap::new(),
|
||
)),
|
||
};
|
||
|
||
let api_router =
|
||
axum::Router::new().route("/login", axum::routing::post(server::api::auth::login));
|
||
let app = axum::Router::new()
|
||
.nest("/api", api_router)
|
||
.with_state(state.clone());
|
||
|
||
// 连续登录 105 次,超过 100 容量上限
|
||
for _ in 0..105 {
|
||
let right_body = serde_json::json!({ "password": admin_pass });
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/login")
|
||
.header("content-type", "application/json")
|
||
.extension(axum::extract::ConnectInfo(std::net::SocketAddr::from((
|
||
[127, 0, 0, 1],
|
||
12345,
|
||
))))
|
||
.body(Body::from(serde_json::to_vec(&right_body).unwrap()))
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::OK);
|
||
}
|
||
|
||
// 验证 sessions 集合保存的数量不超过 MAX_ADMIN_SESSIONS (100)
|
||
let sessions = state.admin_sessions.read().await;
|
||
assert!(sessions.len() <= server::api::MAX_ADMIN_SESSIONS);
|
||
assert_eq!(sessions.len(), 100);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_node_register_rate_limit() {
|
||
let temp_dir = tempfile::tempdir().unwrap();
|
||
let db_path = temp_dir.path().join("reg_limit_db.db");
|
||
let queue_db_path = temp_dir.path().join("reg_limit_queue.db");
|
||
let seeds_dir = temp_dir.path().join("results");
|
||
std::fs::create_dir_all(&seeds_dir).unwrap();
|
||
|
||
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
|
||
let queue = Arc::new(
|
||
SqliteTaskQueue::new(&queue_db_path.to_string_lossy())
|
||
.await
|
||
.unwrap(),
|
||
);
|
||
let scheduler = Arc::new(GridScheduler::new(db.clone(), queue.clone()));
|
||
|
||
let state = AppState {
|
||
db,
|
||
queue,
|
||
scheduler,
|
||
seeds_dir: seeds_dir.to_string_lossy().to_string(),
|
||
rate_limiter: server::api::rate_limit::RateLimiter::new(
|
||
5,
|
||
std::time::Duration::from_secs(300),
|
||
),
|
||
admin_token: None,
|
||
auth_disabled: false,
|
||
admin_sessions: std::sync::Arc::new(tokio::sync::RwLock::new(
|
||
std::collections::HashMap::new(),
|
||
)),
|
||
};
|
||
|
||
// 注册端点专用限流器(count_all=true):对成功请求也计数,模拟生产 main.rs 的注册节流配置。
|
||
let register_limiter =
|
||
server::api::rate_limit::RateLimiter::new_count_all(5, std::time::Duration::from_secs(60));
|
||
let register_rate_limit_layer = axum::middleware::from_fn_with_state(
|
||
register_limiter,
|
||
server::api::rate_limit::rate_limit_middleware,
|
||
);
|
||
|
||
let app = axum::Router::new()
|
||
.route(
|
||
"/api/node/register",
|
||
axum::routing::post(server::api::node::register_node).layer(register_rate_limit_layer),
|
||
)
|
||
.with_state(state);
|
||
|
||
let reg_body = serde_json::json!({
|
||
"node_id": "test-limit-node",
|
||
"max_slots": 4
|
||
});
|
||
|
||
// 5 次以内的注册尝试 ➔ 200
|
||
for _ in 0..5 {
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/node/register")
|
||
.header("content-type", "application/json")
|
||
.extension(axum::extract::ConnectInfo(std::net::SocketAddr::from((
|
||
[127, 0, 0, 1],
|
||
12345,
|
||
))))
|
||
.body(Body::from(serde_json::to_vec(®_body).unwrap()))
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::OK);
|
||
}
|
||
|
||
// 第 6 次触发限流 ➔ 429 TOO_MANY_REQUESTS
|
||
let res = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/node/register")
|
||
.header("content-type", "application/json")
|
||
.extension(axum::extract::ConnectInfo(std::net::SocketAddr::from((
|
||
[127, 0, 0, 1],
|
||
12345,
|
||
))))
|
||
.body(Body::from(serde_json::to_vec(®_body).unwrap()))
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS);
|
||
}
|
||
|
||
/// 回归保护:通用 API 限流器(count_all=false)不应因 /node/register 的成功响应计数。
|
||
/// 历史缺陷:此前中间件对 is_register 无条件计数,导致通用限流器复用时成功注册会把 IP
|
||
/// 锁出整个 /api/*(跨端点连锁)。此测试断言多次成功注册后通用限流器仍放行。
|
||
#[tokio::test]
|
||
async fn test_general_limiter_does_not_count_successful_register() {
|
||
let temp_dir = tempfile::tempdir().unwrap();
|
||
let db_path = temp_dir.path().join("gen_limit_db.db");
|
||
let queue_db_path = temp_dir.path().join("gen_limit_queue.db");
|
||
let seeds_dir = temp_dir.path().join("results");
|
||
std::fs::create_dir_all(&seeds_dir).unwrap();
|
||
|
||
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
|
||
let queue = Arc::new(
|
||
SqliteTaskQueue::new(&queue_db_path.to_string_lossy())
|
||
.await
|
||
.unwrap(),
|
||
);
|
||
let scheduler = Arc::new(GridScheduler::new(db.clone(), queue.clone()));
|
||
|
||
let state = AppState {
|
||
db,
|
||
queue,
|
||
scheduler,
|
||
seeds_dir: seeds_dir.to_string_lossy().to_string(),
|
||
rate_limiter: server::api::rate_limit::RateLimiter::new(
|
||
5,
|
||
std::time::Duration::from_secs(300),
|
||
),
|
||
admin_token: None,
|
||
auth_disabled: false,
|
||
admin_sessions: std::sync::Arc::new(tokio::sync::RwLock::new(
|
||
std::collections::HashMap::new(),
|
||
)),
|
||
};
|
||
|
||
// 通用限流器(new = count_all=false),阈值仅 3,远低于下面的请求数。
|
||
// 若仍对成功注册计数,第 4 次就会 429。
|
||
let general_limiter =
|
||
server::api::rate_limit::RateLimiter::new(3, std::time::Duration::from_secs(60));
|
||
let rate_limit_layer = axum::middleware::from_fn_with_state(
|
||
general_limiter,
|
||
server::api::rate_limit::rate_limit_middleware,
|
||
);
|
||
|
||
let app = axum::Router::new()
|
||
.route(
|
||
"/api/node/register",
|
||
axum::routing::post(server::api::node::register_node),
|
||
)
|
||
.layer(rate_limit_layer)
|
||
.with_state(state);
|
||
|
||
let reg_body = serde_json::json!({
|
||
"node_id": "gen-limit-node",
|
||
"max_slots": 4
|
||
});
|
||
|
||
// 连续 6 次成功注册(远超阈值 3):通用限流器不应计数成功响应,全部应为 200。
|
||
for i in 0..6 {
|
||
// 每次用不同 node_id 避免重复注册逻辑干扰
|
||
let body = serde_json::json!({
|
||
"node_id": format!("gen-limit-node-{}", i),
|
||
"max_slots": 4
|
||
});
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/node/register")
|
||
.header("content-type", "application/json")
|
||
.extension(axum::extract::ConnectInfo(std::net::SocketAddr::from((
|
||
[127, 0, 0, 1],
|
||
12345,
|
||
))))
|
||
.body(Body::from(serde_json::to_vec(&body).unwrap()))
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(
|
||
res.status(),
|
||
StatusCode::OK,
|
||
"第 {} 次注册应成功,通用限流器不应计数成功响应",
|
||
i + 1
|
||
);
|
||
}
|
||
|
||
// 静默 reg_body 未使用的警告(保留以对齐其它测试结构)
|
||
let _ = ®_body;
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_cors_same_origin_and_local_policy() {
|
||
let temp_dir = tempfile::tempdir().unwrap();
|
||
let db_path = temp_dir.path().join("cors_db.db");
|
||
let queue_db_path = temp_dir.path().join("cors_queue.db");
|
||
let seeds_dir = temp_dir.path().join("results");
|
||
std::fs::create_dir_all(&seeds_dir).unwrap();
|
||
|
||
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
|
||
let queue = Arc::new(
|
||
SqliteTaskQueue::new(&queue_db_path.to_string_lossy())
|
||
.await
|
||
.unwrap(),
|
||
);
|
||
let scheduler = Arc::new(GridScheduler::new(db.clone(), queue.clone()));
|
||
|
||
let state = AppState {
|
||
db,
|
||
queue,
|
||
scheduler,
|
||
seeds_dir: seeds_dir.to_string_lossy().to_string(),
|
||
rate_limiter: server::api::rate_limit::RateLimiter::new(
|
||
100,
|
||
std::time::Duration::from_secs(300),
|
||
),
|
||
admin_token: None,
|
||
auth_disabled: false,
|
||
admin_sessions: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
|
||
};
|
||
|
||
let app = axum::Router::new()
|
||
.route(
|
||
"/api/status",
|
||
axum::routing::get(server::api::status::get_status),
|
||
)
|
||
.layer(server::cors::build_cors_layer())
|
||
.with_state(state);
|
||
|
||
// 1. 本地 localhost 请求 -> 允许 CORS
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri("/api/status")
|
||
.header("origin", "http://localhost:5173")
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::OK);
|
||
assert_eq!(
|
||
res.headers()
|
||
.get("access-control-allow-origin")
|
||
.unwrap()
|
||
.to_str()
|
||
.unwrap(),
|
||
"http://localhost:5173"
|
||
);
|
||
|
||
// 2. 本地 127.0.0.1 请求 -> 允许 CORS
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri("/api/status")
|
||
.header("origin", "http://127.0.0.1:3000")
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::OK);
|
||
assert_eq!(
|
||
res.headers()
|
||
.get("access-control-allow-origin")
|
||
.unwrap()
|
||
.to_str()
|
||
.unwrap(),
|
||
"http://127.0.0.1:3000"
|
||
);
|
||
|
||
// 3. 同源请求 (Origin 匹配 Host 标头) -> 允许 CORS
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri("/api/status")
|
||
.header("host", "192.168.1.100:8090")
|
||
.header("origin", "http://192.168.1.100:8090")
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::OK);
|
||
assert_eq!(
|
||
res.headers()
|
||
.get("access-control-allow-origin")
|
||
.unwrap()
|
||
.to_str()
|
||
.unwrap(),
|
||
"http://192.168.1.100:8090"
|
||
);
|
||
|
||
// 4. 外部非法跨源请求 -> 拒绝 CORS
|
||
let res = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri("/api/status")
|
||
.header("host", "192.168.1.100:8090")
|
||
.header("origin", "https://attacker.example.com")
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::OK);
|
||
assert!(res.headers().get("access-control-allow-origin").is_none());
|
||
}
|
||
|
||
|
||
#[tokio::test]
|
||
async fn test_node_disable_enable_flow() {
|
||
use common::models::{GridAxisValue, GridPointParams, TaskSpec};
|
||
use uuid::Uuid;
|
||
|
||
let temp_dir = tempfile::tempdir().unwrap();
|
||
let db_path = temp_dir.path().join("disable_db.db");
|
||
let queue_db_path = temp_dir.path().join("disable_queue.db");
|
||
let seeds_dir = temp_dir.path().join("results");
|
||
std::fs::create_dir_all(&seeds_dir).unwrap();
|
||
|
||
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
|
||
let queue = Arc::new(
|
||
SqliteTaskQueue::new(&queue_db_path.to_string_lossy())
|
||
.await
|
||
.unwrap(),
|
||
);
|
||
let scheduler = Arc::new(GridScheduler::new(db.clone(), queue.clone()));
|
||
|
||
// 1. 注册并审批 node-disable-test(→ online),颁发 token
|
||
let reg = common::models::NodeRegisterRequest {
|
||
node_id: "node-disable-test".to_string(),
|
||
max_slots: 2,
|
||
};
|
||
db.register_node(®).await.unwrap();
|
||
let token = db.approve_node("node-disable-test").await.unwrap();
|
||
|
||
// 预置一个 pending_approval 节点(用于后续 409 断言)
|
||
let reg_pending = common::models::NodeRegisterRequest {
|
||
node_id: "node-pending".to_string(),
|
||
max_slots: 2,
|
||
};
|
||
db.register_node(®_pending).await.unwrap();
|
||
|
||
let state = AppState {
|
||
db: db.clone(),
|
||
queue: queue.clone(),
|
||
scheduler,
|
||
seeds_dir: seeds_dir.to_string_lossy().to_string(),
|
||
rate_limiter: server::api::rate_limit::RateLimiter::new(
|
||
5,
|
||
std::time::Duration::from_secs(300),
|
||
),
|
||
admin_token: Some("admin-secret".to_string()),
|
||
auth_disabled: false,
|
||
admin_sessions: std::sync::Arc::new(tokio::sync::RwLock::new(
|
||
std::collections::HashMap::new(),
|
||
)),
|
||
};
|
||
|
||
let api_router = axum::Router::new()
|
||
.route(
|
||
"/task/claim",
|
||
axum::routing::post(server::api::task::claim_task),
|
||
)
|
||
.route(
|
||
"/node/heartbeat",
|
||
axum::routing::post(server::api::node::heartbeat_node),
|
||
)
|
||
.route(
|
||
"/admin/nodes/:node_id/disable",
|
||
axum::routing::post(server::api::admin::disable_node),
|
||
)
|
||
.route(
|
||
"/admin/nodes/:node_id/enable",
|
||
axum::routing::post(server::api::admin::enable_node),
|
||
);
|
||
let auth_layer =
|
||
axum::middleware::from_fn_with_state(state.clone(), server::api::auth_middleware);
|
||
let app = axum::Router::new()
|
||
.nest("/api", api_router.layer(auth_layer))
|
||
.with_state(state);
|
||
|
||
// 2. 基线:向队列 push 一个任务,claim 应返回 {"status":"ok"}
|
||
let task = TaskSpec {
|
||
task_id: Uuid::new_v4(),
|
||
point_name: "p_disable_test".to_string(),
|
||
params: GridPointParams {
|
||
teff: GridAxisValue::from_value(20000.0),
|
||
logg: GridAxisValue::from_value(5.0),
|
||
loghe: GridAxisValue::from_value(-2.0),
|
||
logc: GridAxisValue::from_value(-2.0),
|
||
logn: GridAxisValue::from_value(-2.0),
|
||
logo: GridAxisValue::from_value(-2.0),
|
||
},
|
||
seed_point_name: None,
|
||
timeout_sec: 60,
|
||
workflow_name: Some("wf_test".to_string()),
|
||
wave: 0,
|
||
..Default::default()
|
||
};
|
||
queue.push_task(&task).await.unwrap();
|
||
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/task/claim")
|
||
.header("authorization", format!("Bearer {}", token))
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::OK);
|
||
let body: serde_json::Value = serde_json::from_slice(
|
||
&axum::body::to_bytes(res.into_body(), usize::MAX)
|
||
.await
|
||
.unwrap(),
|
||
)
|
||
.unwrap();
|
||
assert_eq!(body["status"], "ok", "online 节点应能领用任务");
|
||
|
||
// 3. admin 停用节点 → 200;状态切为 disabled
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/admin/nodes/node-disable-test/disable")
|
||
.header("authorization", "Bearer admin-secret")
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::OK);
|
||
assert!(
|
||
db.is_node_disabled("node-disable-test").await.unwrap(),
|
||
"停用后 is_node_disabled 应为 true"
|
||
);
|
||
|
||
// 3a. 被停用节点从「在线节点」列表中移除(/api/status 在线计数依据)
|
||
let active = db.get_active_nodes().await.unwrap();
|
||
assert!(
|
||
!active.iter().any(|n| n.node_id == "node-disable-test"),
|
||
"被停用节点不应出现在在线列表"
|
||
);
|
||
|
||
// 4. 被停用节点再次 claim → {"status":"disabled"}(不再分发,HTTP 仍 200)
|
||
// 再 push 一个任务确认它真的领不到。
|
||
let task2 = TaskSpec {
|
||
task_id: Uuid::new_v4(),
|
||
..task.clone()
|
||
};
|
||
queue.push_task(&task2).await.unwrap();
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/task/claim")
|
||
.header("authorization", format!("Bearer {}", token))
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::OK);
|
||
let body: serde_json::Value = serde_json::from_slice(
|
||
&axum::body::to_bytes(res.into_body(), usize::MAX)
|
||
.await
|
||
.unwrap(),
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
body["status"], "disabled",
|
||
"被停用节点 claim 必须返回 disabled(不可分发任务)"
|
||
);
|
||
// 队列里应仍有 pending 任务(未被领用):注册第二个 online 节点领用,
|
||
// 应当拿到刚才 push 的 task2,证明停用节点没有消费它。
|
||
let reg2 = common::models::NodeRegisterRequest {
|
||
node_id: "node-helper".to_string(),
|
||
max_slots: 2,
|
||
};
|
||
db.register_node(®2).await.unwrap();
|
||
let token2 = db.approve_node("node-helper").await.unwrap();
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/task/claim")
|
||
.header("authorization", format!("Bearer {}", token2))
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
let body: serde_json::Value = serde_json::from_slice(
|
||
&axum::body::to_bytes(res.into_body(), usize::MAX)
|
||
.await
|
||
.unwrap(),
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
body["status"], "ok",
|
||
"另一在线节点应能领用到停用节点未消费的任务"
|
||
);
|
||
|
||
// 5. 被停用节点心跳:状态保持 disabled(不被复活),但 cpu/mem/心跳已刷新
|
||
let hb = serde_json::json!({"node_id":"node-disable-test","active_slots":0,"cpu_usage":77.7,"memory_usage":88.8});
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/node/heartbeat")
|
||
.header("authorization", format!("Bearer {}", token))
|
||
.header("content-type", "application/json")
|
||
.body(Body::from(serde_json::to_vec(&hb).unwrap()))
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::OK);
|
||
let nodes = db.list_nodes_with_credentials().await.unwrap();
|
||
let me = nodes
|
||
.iter()
|
||
.find(|n| n.node_id == "node-disable-test")
|
||
.unwrap();
|
||
assert_eq!(me.status, "disabled", "心跳不得把 disabled 复活为 online");
|
||
assert!(
|
||
(me.cpu_usage - 77.7).abs() < 0.01,
|
||
"心跳应刷新 cpu_usage(管理员仍可见节点存活)"
|
||
);
|
||
|
||
// 6. admin 启用节点 → 200;状态切为 offline(待心跳自然翻为 online)
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/admin/nodes/node-disable-test/enable")
|
||
.header("authorization", "Bearer admin-secret")
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::OK);
|
||
|
||
// 7. 启用后心跳 → status 翻为 online,重新出现在在线列表,可恢复领用
|
||
let hb = serde_json::json!({"node_id":"node-disable-test","active_slots":0,"cpu_usage":10.0,"memory_usage":20.0});
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/node/heartbeat")
|
||
.header("authorization", format!("Bearer {}", token))
|
||
.header("content-type", "application/json")
|
||
.body(Body::from(serde_json::to_vec(&hb).unwrap()))
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::OK);
|
||
assert!(
|
||
db.get_active_nodes()
|
||
.await
|
||
.unwrap()
|
||
.iter()
|
||
.any(|n| n.node_id == "node-disable-test"),
|
||
"启用并心跳后应重新出现在线"
|
||
);
|
||
|
||
// 8. 状态幂等保护:对 online 节点再次 enable → 409
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/admin/nodes/node-disable-test/enable")
|
||
.header("authorization", "Bearer admin-secret")
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(
|
||
res.status(),
|
||
StatusCode::CONFLICT,
|
||
"对非 disabled 节点 enable 应返回 409"
|
||
);
|
||
|
||
// 9. 对 pending_approval 节点 disable → 409(审批流程独立)
|
||
let res = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/admin/nodes/node-pending/disable")
|
||
.header("authorization", "Bearer admin-secret")
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(
|
||
res.status(),
|
||
StatusCode::CONFLICT,
|
||
"待审批节点不可停用(仅 online/offline 可停用)"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_admin_set_node_quota_and_heartbeat_sync() {
|
||
// 验证动态 CPU 槽位配额下发链路(见 docs/dynamic_cpu_slots_design.md):
|
||
// admin POST /quota 设 admin_max_slots → 落库 nodes.admin_max_slots →
|
||
// list_nodes_with_credentials 返回配额 → 心跳响应体透传 admin_max_slots。
|
||
// 覆盖:设值 / 清除(null) / 负数 400 / 不存在节点 404。
|
||
let temp_dir = tempfile::tempdir().unwrap();
|
||
let db_path = temp_dir.path().join("quota_db.db");
|
||
let queue_db_path = temp_dir.path().join("quota_queue.db");
|
||
let seeds_dir = temp_dir.path().join("results");
|
||
std::fs::create_dir_all(&seeds_dir).unwrap();
|
||
|
||
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
|
||
let queue = Arc::new(
|
||
SqliteTaskQueue::new(&queue_db_path.to_string_lossy())
|
||
.await
|
||
.unwrap(),
|
||
);
|
||
let scheduler = Arc::new(GridScheduler::new(db.clone(), queue.clone()));
|
||
|
||
// 注册并审批 node-quota-test,颁发 token
|
||
let reg = common::models::NodeRegisterRequest {
|
||
node_id: "node-quota-test".to_string(),
|
||
max_slots: 4,
|
||
};
|
||
db.register_node(®).await.unwrap();
|
||
let token = db.approve_node("node-quota-test").await.unwrap();
|
||
|
||
let state = AppState {
|
||
db: db.clone(),
|
||
queue: queue.clone(),
|
||
scheduler,
|
||
seeds_dir: seeds_dir.to_string_lossy().to_string(),
|
||
rate_limiter: server::api::rate_limit::RateLimiter::new(
|
||
5,
|
||
std::time::Duration::from_secs(300),
|
||
),
|
||
admin_token: Some("admin-secret".to_string()),
|
||
auth_disabled: false,
|
||
admin_sessions: std::sync::Arc::new(tokio::sync::RwLock::new(
|
||
std::collections::HashMap::new(),
|
||
)),
|
||
};
|
||
|
||
let api_router = axum::Router::new()
|
||
.route(
|
||
"/node/heartbeat",
|
||
axum::routing::post(server::api::node::heartbeat_node),
|
||
)
|
||
.route(
|
||
"/admin/nodes/:node_id/quota",
|
||
axum::routing::post(server::api::admin::set_node_quota),
|
||
);
|
||
let auth_layer =
|
||
axum::middleware::from_fn_with_state(state.clone(), server::api::auth_middleware);
|
||
let app = axum::Router::new()
|
||
.nest("/api", api_router.layer(auth_layer))
|
||
.with_state(state);
|
||
|
||
// 1. 基线心跳:admin_max_slots 应为 null(无配额限制)
|
||
let hb = serde_json::json!({"node_id":"node-quota-test","active_slots":0,"cpu_usage":10.0,"memory_usage":20.0});
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/node/heartbeat")
|
||
.header("authorization", format!("Bearer {}", token))
|
||
.header("content-type", "application/json")
|
||
.body(Body::from(serde_json::to_vec(&hb).unwrap()))
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::OK);
|
||
let body: serde_json::Value = serde_json::from_slice(
|
||
&axum::body::to_bytes(res.into_body(), usize::MAX)
|
||
.await
|
||
.unwrap(),
|
||
)
|
||
.unwrap();
|
||
assert_eq!(body["status"], "ok");
|
||
assert!(
|
||
body["admin_max_slots"].is_null(),
|
||
"未设配额时心跳响应 admin_max_slots 应为 null"
|
||
);
|
||
|
||
// 2. admin 设配额 = 2 → 200
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/admin/nodes/node-quota-test/quota")
|
||
.header("authorization", "Bearer admin-secret")
|
||
.header("content-type", "application/json")
|
||
.body(Body::from(
|
||
serde_json::to_vec(&serde_json::json!({"admin_max_slots": 2})).unwrap(),
|
||
))
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::OK);
|
||
|
||
// 2a. 配额落库(list_nodes_with_credentials)
|
||
let nodes = db.list_nodes_with_credentials().await.unwrap();
|
||
let me = nodes
|
||
.iter()
|
||
.find(|n| n.node_id == "node-quota-test")
|
||
.unwrap();
|
||
assert_eq!(me.admin_max_slots, Some(2), "配额应已落库为 Some(2)");
|
||
|
||
// 3. 心跳响应透传 admin_max_slots = 2
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/node/heartbeat")
|
||
.header("authorization", format!("Bearer {}", token))
|
||
.header("content-type", "application/json")
|
||
.body(Body::from(serde_json::to_vec(&hb).unwrap()))
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::OK);
|
||
let body: serde_json::Value = serde_json::from_slice(
|
||
&axum::body::to_bytes(res.into_body(), usize::MAX)
|
||
.await
|
||
.unwrap(),
|
||
)
|
||
.unwrap();
|
||
assert_eq!(body["admin_max_slots"], 2, "心跳响应应透传配额 2");
|
||
|
||
// 3a. 请求体缺 admin_max_slots 字段(畸形输入)→ 400,绝不能静默当作「清除配额」
|
||
// (审查修复 M1:Option<i32> 缺字段默认 None 会把畸形请求误判为清除限制)。
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/admin/nodes/node-quota-test/quota")
|
||
.header("authorization", "Bearer admin-secret")
|
||
.header("content-type", "application/json")
|
||
.body(Body::from(
|
||
serde_json::to_vec(&serde_json::json!({})).unwrap(),
|
||
))
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(
|
||
res.status(),
|
||
StatusCode::BAD_REQUEST,
|
||
"缺字段应 400 而非静默清除配额"
|
||
);
|
||
// 畸形请求无副作用:既有配额仍为 Some(2),未被静默清除
|
||
let nodes = db.list_nodes_with_credentials().await.unwrap();
|
||
let me = nodes
|
||
.iter()
|
||
.find(|n| n.node_id == "node-quota-test")
|
||
.unwrap();
|
||
assert_eq!(me.admin_max_slots, Some(2), "畸形请求不应改动既有配额");
|
||
|
||
// 4. 设 null 清除配额 → 心跳响应 admin_max_slots = null
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/admin/nodes/node-quota-test/quota")
|
||
.header("authorization", "Bearer admin-secret")
|
||
.header("content-type", "application/json")
|
||
.body(Body::from(
|
||
serde_json::to_vec(&serde_json::json!({"admin_max_slots": null})).unwrap(),
|
||
))
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::OK);
|
||
let nodes = db.list_nodes_with_credentials().await.unwrap();
|
||
let me = nodes
|
||
.iter()
|
||
.find(|n| n.node_id == "node-quota-test")
|
||
.unwrap();
|
||
assert_eq!(me.admin_max_slots, None, "清除后配额应为 None");
|
||
|
||
// 5. 负数 → 400
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/admin/nodes/node-quota-test/quota")
|
||
.header("authorization", "Bearer admin-secret")
|
||
.header("content-type", "application/json")
|
||
.body(Body::from(
|
||
serde_json::to_vec(&serde_json::json!({"admin_max_slots": -1})).unwrap(),
|
||
))
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::BAD_REQUEST, "负数配额应 400");
|
||
|
||
// 6. 不存在节点 → 404
|
||
let res = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/admin/nodes/ghost-node/quota")
|
||
.header("authorization", "Bearer admin-secret")
|
||
.header("content-type", "application/json")
|
||
.body(Body::from(
|
||
serde_json::to_vec(&serde_json::json!({"admin_max_slots": 1})).unwrap(),
|
||
))
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::NOT_FOUND, "不存在节点应 404");
|
||
}
|
||
|
||
// ===== 工作流执行观测端点测试(stats / points / point detail) =====
|
||
|
||
/// 测试专用:走真实写路径派发并回报一个网格点任务。
|
||
///
|
||
/// `insert_task` → `record_task_report` 会回填 `grid_points.tlusty_success_method`、
|
||
/// `attempt_count` 与 `tasks.completed_at`,与生产链路一致(不绕过任何状态机逻辑)。
|
||
async fn dispatch_and_report(
|
||
db: &Database,
|
||
wf: &str,
|
||
p: &common::models::GridPointParams,
|
||
strategy: &str,
|
||
seed: Option<String>,
|
||
converged: bool,
|
||
) {
|
||
let task_id = uuid::Uuid::new_v4();
|
||
let spec = common::models::TaskSpec {
|
||
task_id,
|
||
point_name: p.model_name(),
|
||
params: p.clone(),
|
||
seed_point_name: seed.clone(),
|
||
timeout_sec: 7200,
|
||
workflow_name: Some(wf.to_string()),
|
||
wave: 0,
|
||
// Phase 6 起策略链首项即"当前策略"(归因/过滤全派生自它)。
|
||
tlusty_config: common::models::PhaseConfig {
|
||
strategies: vec![strategy.to_string()],
|
||
..common::models::PhaseConfig::default_tlusty()
|
||
},
|
||
..Default::default()
|
||
};
|
||
db.insert_task(&spec).await.unwrap();
|
||
// 构造合法 ModelSummary 作为 summary_json(让 record_task_report 能解析合并写入 grid_points)。
|
||
let summary = common::models::ModelSummary {
|
||
name: p.model_name(),
|
||
params: p.clone(),
|
||
stages: Vec::new(),
|
||
result_valid: converged,
|
||
final_max_relc: if converged { Some(0.0005) } else { Some(9.5e5) },
|
||
final_chmax: Some(0.001),
|
||
seed,
|
||
atmosphere_has_nan: false,
|
||
synspec_rc: None,
|
||
synspec_error: None,
|
||
synspec_sec: None,
|
||
elapsed_sec: 120.0,
|
||
energy_check: None,
|
||
temp_check: None,
|
||
emflux_check: None,
|
||
bfac_check: None,
|
||
note: None,
|
||
};
|
||
let report = common::models::TaskReport {
|
||
task_id,
|
||
point_name: p.model_name(),
|
||
params: Some(p.clone()),
|
||
node_id: "test-node".to_string(),
|
||
status: if converged {
|
||
common::models::TaskStatus::Completed
|
||
} else {
|
||
common::models::TaskStatus::Failed
|
||
},
|
||
result_valid: converged,
|
||
max_relc: if converged { Some(0.0005) } else { Some(9.5e5) },
|
||
atmosphere_has_nan: false,
|
||
elapsed_sec: 120.0,
|
||
error_message: if converged {
|
||
None
|
||
} else {
|
||
Some("nl stage diverged".to_string())
|
||
},
|
||
summary_json: serde_json::to_string(&summary).unwrap(),
|
||
failed_stage: None,
|
||
};
|
||
db.record_task_report(&report, wf).await.unwrap();
|
||
}
|
||
|
||
fn wf_stats_test_params(teff: f64, logc: f64) -> common::models::GridPointParams {
|
||
common::models::GridPointParams {
|
||
teff: teff.into(),
|
||
logg: 5.0.into(),
|
||
loghe: (-2.0).into(),
|
||
logc: logc.into(),
|
||
logn: (-4.0).into(),
|
||
logo: (-4.0).into(),
|
||
}
|
||
}
|
||
|
||
/// GET /api/workflows/:name/stats:鉴权、pending/queued 拆分、收敛手段归因、
|
||
/// wave 分布、近似 ETA、空工作流与未知工作流。
|
||
#[tokio::test]
|
||
async fn test_wf_stats_endpoint() {
|
||
let temp_dir = tempfile::tempdir().unwrap();
|
||
let db_path = temp_dir.path().join("wfstats_db.db");
|
||
let queue_db_path = temp_dir.path().join("wfstats_queue.db");
|
||
let seeds_dir = temp_dir.path().join("results");
|
||
std::fs::create_dir_all(&seeds_dir).unwrap();
|
||
|
||
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
|
||
let queue = Arc::new(
|
||
SqliteTaskQueue::new(&queue_db_path.to_string_lossy())
|
||
.await
|
||
.unwrap(),
|
||
);
|
||
let scheduler = Arc::new(GridScheduler::new(db.clone(), queue.clone()));
|
||
|
||
let state = AppState {
|
||
db: db.clone(),
|
||
queue,
|
||
scheduler,
|
||
seeds_dir: seeds_dir.to_string_lossy().to_string(),
|
||
rate_limiter: server::api::rate_limit::RateLimiter::new(
|
||
5,
|
||
std::time::Duration::from_secs(300),
|
||
),
|
||
admin_token: Some("admin-secret".to_string()),
|
||
auth_disabled: false,
|
||
admin_sessions: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
|
||
};
|
||
|
||
let api_router = axum::Router::new().route(
|
||
"/workflows/:name/stats",
|
||
axum::routing::get(server::api::workflow::get_workflow_stats),
|
||
);
|
||
let auth_layer =
|
||
axum::middleware::from_fn_with_state(state.clone(), server::api::auth_middleware);
|
||
let app = axum::Router::new()
|
||
.nest("/api", api_router.layer(auth_layer))
|
||
.with_state(state);
|
||
|
||
// ---- 数据准备:wf_stats 7 个点,覆盖全部状态与三种收敛手段 ----
|
||
let yaml = "grid:\n teff: [20000]\n logg: [5.0]\n loghe: [-2]\n logc: [-4]\n logn: [-4]\n logo: [-4]";
|
||
db.upsert_workflow("wf_stats", Some("统计测试"), yaml, "running")
|
||
.await
|
||
.unwrap();
|
||
db.upsert_workflow("wf_empty", None, yaml, "idle")
|
||
.await
|
||
.unwrap();
|
||
|
||
let p_pending = wf_stats_test_params(20000.0, -4.0); // wave 0, pending
|
||
let p_queued = wf_stats_test_params(25000.0, -4.0); // wave 0, queued
|
||
let p_running = wf_stats_test_params(30000.0, -4.0); // wave 0, running
|
||
let p_cold = wf_stats_test_params(35000.0, -3.0); // wave 1, cold_run 收敛
|
||
let p_seed = wf_stats_test_params(40000.0, -3.0); // wave 1, seed_step 收敛
|
||
let p_failed = wf_stats_test_params(45000.0, -2.0); // wave 2, failed
|
||
let p_imported = wf_stats_test_params(50000.0, -2.0); // wave 2, imported 收敛
|
||
|
||
db.upsert_grid_point(&p_pending, 0, "wf_stats")
|
||
.await
|
||
.unwrap();
|
||
db.upsert_grid_point(&p_queued, 0, "wf_stats")
|
||
.await
|
||
.unwrap();
|
||
db.upsert_grid_point(&p_running, 0, "wf_stats")
|
||
.await
|
||
.unwrap();
|
||
db.upsert_grid_point(&p_cold, 1, "wf_stats").await.unwrap();
|
||
db.upsert_grid_point(&p_seed, 1, "wf_stats").await.unwrap();
|
||
db.upsert_grid_point(&p_failed, 2, "wf_stats")
|
||
.await
|
||
.unwrap();
|
||
db.upsert_grid_point(&p_imported, 2, "wf_stats")
|
||
.await
|
||
.unwrap();
|
||
|
||
db.update_grid_status(
|
||
&p_queued.model_name(),
|
||
common::models::GridPointStatus::Queued,
|
||
"wf_stats",
|
||
)
|
||
.await
|
||
.unwrap();
|
||
db.mark_grid_point_running(&p_running.model_name(), "wf_stats")
|
||
.await
|
||
.unwrap();
|
||
|
||
dispatch_and_report(&db, "wf_stats", &p_cold, "cold_run", None, true).await;
|
||
dispatch_and_report(
|
||
&db,
|
||
"wf_stats",
|
||
&p_seed,
|
||
"seed_step",
|
||
Some(p_cold.model_name()),
|
||
true,
|
||
)
|
||
.await;
|
||
dispatch_and_report(&db, "wf_stats", &p_failed, "cold_run", None, false).await;
|
||
mark_imported(&db, &p_imported.model_name(), "wf_stats", &p_imported, "cold_run").await;
|
||
|
||
// 回拨任务创建时间:测试内 insert/report 同秒完成,墙钟差为 0 会被 ETA 估算
|
||
// 过滤(avg 必须 > 0);造 120s 的真实感样本,使 avg_point_sec/eta_sec 非空。
|
||
{
|
||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||
conn.execute(
|
||
"UPDATE tasks SET created_at = datetime('now', '-120 seconds') WHERE workflow_name = 'wf_stats'",
|
||
[],
|
||
)
|
||
.unwrap();
|
||
}
|
||
|
||
// ---- 1. 无 token → 401 ----
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri("/api/workflows/wf_stats/stats")
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
|
||
|
||
// ---- 2. admin token → 200,各口径分别计数 ----
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri("/api/workflows/wf_stats/stats")
|
||
.header("authorization", "Bearer admin-secret")
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::OK);
|
||
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
|
||
.await
|
||
.unwrap();
|
||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||
assert_eq!(json["success"], true);
|
||
let data = &json["data"];
|
||
assert_eq!(data["name"], "wf_stats");
|
||
assert_eq!(data["status"], "running");
|
||
assert_eq!(data["total"], 7);
|
||
assert_eq!(data["pending"], 1, "pending 与 queued 必须分开计数");
|
||
assert_eq!(data["queued"], 1);
|
||
assert_eq!(data["running"], 1);
|
||
assert_eq!(data["completed"], 3);
|
||
assert_eq!(data["failed"], 1);
|
||
assert_eq!(data["cold_run_converged"], 2);
|
||
assert_eq!(data["seed_step_converged"], 1);
|
||
// wave 分布:3 个波次,wave0 共 3 点全未收敛
|
||
let waves = data["waves"].as_array().unwrap();
|
||
assert_eq!(waves.len(), 3);
|
||
assert_eq!(waves[0]["wave"], 0);
|
||
assert_eq!(waves[0]["total"], 3);
|
||
assert_eq!(waves[0]["completed"], 0);
|
||
assert_eq!(waves[1]["completed"], 2);
|
||
assert_eq!(waves[2]["failed"], 1);
|
||
// 有已完成任务且有剩余点 → ETA 可估算。
|
||
// P3 后 avg 取精确 elapsed_sec(夹具每次回报 120s),无在线节点按串行兜底:
|
||
// eta = 120 × 剩余 3 点 ÷ 1 槽位 = 360s。
|
||
let avg = data["avg_point_sec"].as_f64().expect("应给出平均耗时");
|
||
assert!((avg - 120.0).abs() < 1.0, "avg 应为精确 elapsed_sec 均值");
|
||
let eta = data["eta_sec"].as_f64().expect("应给出 ETA");
|
||
assert!((eta - 360.0).abs() < 5.0, "并发感知 ETA(串行兜底)");
|
||
|
||
// ---- 3. 空工作流:total=0、waves 空、ETA 为 null ----
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri("/api/workflows/wf_empty/stats")
|
||
.header("authorization", "Bearer admin-secret")
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::OK);
|
||
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
|
||
.await
|
||
.unwrap();
|
||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||
let data = &json["data"];
|
||
assert_eq!(data["total"], 0);
|
||
assert!(data["waves"].as_array().unwrap().is_empty());
|
||
assert!(data["avg_point_sec"].is_null());
|
||
assert!(data["eta_sec"].is_null());
|
||
|
||
// ---- 4. 未知工作流 → 404 ----
|
||
let res = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri("/api/workflows/nonexistent/stats")
|
||
.header("authorization", "Bearer admin-secret")
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::NOT_FOUND);
|
||
}
|
||
|
||
/// 观测端点(points / point detail)共享夹具:6 个点覆盖全部状态与三种收敛手段,
|
||
/// 其中 `rescued` 有 2 次尝试(冷启动失败 → 种子步进救回),用于验证"最近尝试"取值。
|
||
///
|
||
/// 时间戳回拨:除 rescued 的 seed_step 尝试外全部 -60s,使"最近尝试"与时间排序确定
|
||
/// (测试内回报同秒落库,datetime('now') 秒级精度下必须人造先后)。
|
||
struct ObsNames {
|
||
pending: String,
|
||
cold: String,
|
||
seed: String,
|
||
failed: String,
|
||
rescued: String,
|
||
imported: String,
|
||
}
|
||
|
||
async fn seed_obs_fixture(db: &Database, db_path: &std::path::Path, wf: &str) -> ObsNames {
|
||
let yaml = "grid:\n teff: [20000]\n logg: [5.0]\n loghe: [-2]\n logc: [-4]\n logn: [-4]\n logo: [-4]";
|
||
db.upsert_workflow(wf, Some("观测测试"), yaml, "running")
|
||
.await
|
||
.unwrap();
|
||
|
||
let p_pending = wf_stats_test_params(20000.0, -4.0);
|
||
let p_cold = wf_stats_test_params(25000.0, -4.0);
|
||
let p_seed = wf_stats_test_params(30000.0, -4.0);
|
||
let p_failed = wf_stats_test_params(35000.0, -4.0);
|
||
let p_rescued = wf_stats_test_params(40000.0, -4.0);
|
||
let p_imported = wf_stats_test_params(50000.0, -4.0);
|
||
|
||
for p in [&p_pending, &p_cold, &p_seed, &p_failed, &p_rescued] {
|
||
db.upsert_grid_point(p, 0, wf).await.unwrap();
|
||
}
|
||
db.upsert_grid_point(&p_imported, 1, wf).await.unwrap();
|
||
|
||
dispatch_and_report(db, wf, &p_cold, "cold_run", None, true).await;
|
||
dispatch_and_report(
|
||
db,
|
||
wf,
|
||
&p_seed,
|
||
"seed_step",
|
||
Some(p_cold.model_name()),
|
||
true,
|
||
)
|
||
.await;
|
||
dispatch_and_report(db, wf, &p_failed, "cold_run", None, false).await;
|
||
// rescued:先冷启动失败,再种子步进救回(2 次尝试,最终 converged/seed_step)
|
||
dispatch_and_report(db, wf, &p_rescued, "cold_run", None, false).await;
|
||
dispatch_and_report(
|
||
db,
|
||
wf,
|
||
&p_rescued,
|
||
"seed_step",
|
||
Some(p_cold.model_name()),
|
||
true,
|
||
)
|
||
.await;
|
||
mark_imported(&db, &p_imported.model_name(), wf, &p_imported, "cold_run").await;
|
||
|
||
let conn = rusqlite::Connection::open(db_path).unwrap();
|
||
conn.execute(
|
||
"UPDATE tasks SET created_at = datetime('now','-60 seconds'),
|
||
completed_at = datetime('now','-60 seconds')
|
||
WHERE workflow_name = ?1 AND NOT (point_name = ?2 AND json_extract(tlusty_strategies, '$[0]') = 'seed_step')",
|
||
rusqlite::params![wf, p_rescued.model_name()],
|
||
)
|
||
.unwrap();
|
||
drop(conn);
|
||
|
||
ObsNames {
|
||
pending: p_pending.model_name(),
|
||
cold: p_cold.model_name(),
|
||
seed: p_seed.model_name(),
|
||
failed: p_failed.model_name(),
|
||
rescued: p_rescued.model_name(),
|
||
imported: p_imported.model_name(),
|
||
}
|
||
}
|
||
|
||
/// GET /api/workflows/:name/points:过滤 / 分页 / 排序 / 白名单 400 / 最近尝试 JOIN。
|
||
#[tokio::test]
|
||
async fn test_wf_points_endpoint() {
|
||
let temp_dir = tempfile::tempdir().unwrap();
|
||
let db_path = temp_dir.path().join("wfpts_db.db");
|
||
let queue_db_path = temp_dir.path().join("wfpts_queue.db");
|
||
let seeds_dir = temp_dir.path().join("results");
|
||
std::fs::create_dir_all(&seeds_dir).unwrap();
|
||
|
||
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
|
||
let queue = Arc::new(
|
||
SqliteTaskQueue::new(&queue_db_path.to_string_lossy())
|
||
.await
|
||
.unwrap(),
|
||
);
|
||
let scheduler = Arc::new(GridScheduler::new(db.clone(), queue.clone()));
|
||
|
||
let state = AppState {
|
||
db: db.clone(),
|
||
queue,
|
||
scheduler,
|
||
seeds_dir: seeds_dir.to_string_lossy().to_string(),
|
||
rate_limiter: server::api::rate_limit::RateLimiter::new(
|
||
5,
|
||
std::time::Duration::from_secs(300),
|
||
),
|
||
admin_token: Some("admin-secret".to_string()),
|
||
auth_disabled: false,
|
||
admin_sessions: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
|
||
};
|
||
|
||
let api_router = axum::Router::new().route(
|
||
"/workflows/:name/points",
|
||
axum::routing::get(server::api::workflow::get_workflow_points),
|
||
);
|
||
let auth_layer =
|
||
axum::middleware::from_fn_with_state(state.clone(), server::api::auth_middleware);
|
||
let app = axum::Router::new()
|
||
.nest("/api", api_router.layer(auth_layer))
|
||
.with_state(state);
|
||
|
||
let n = seed_obs_fixture(&db, &db_path, "wf_pts").await;
|
||
let yaml = "grid:\n teff: [20000]\n logg: [5.0]\n loghe: [-2]\n logc: [-4]\n logn: [-4]\n logo: [-4]";
|
||
db.upsert_workflow("wf_pts_empty", None, yaml, "idle")
|
||
.await
|
||
.unwrap();
|
||
|
||
/// 带 admin token 请求 points 端点,返回 (状态码, data JSON)。
|
||
async fn get_points(app: &axum::Router, uri: &str) -> (StatusCode, serde_json::Value) {
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri(uri)
|
||
.header("authorization", "Bearer admin-secret")
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
let status = res.status();
|
||
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
|
||
.await
|
||
.unwrap();
|
||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||
(status, json["data"].clone())
|
||
}
|
||
|
||
fn find_point<'a>(data: &'a serde_json::Value, name: &str) -> &'a serde_json::Value {
|
||
data["points"]
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.find(|p| p["name"] == name)
|
||
.expect("点应存在于返回页")
|
||
}
|
||
|
||
// ---- 1. 无 token → 401 ----
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri("/api/workflows/wf_pts/points")
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
|
||
|
||
// ---- 2. 默认查询:total=6,按 wave→cno_sum→teff 排序 ----
|
||
let (st, data) = get_points(&app, "/api/workflows/wf_pts/points").await;
|
||
assert_eq!(st, StatusCode::OK);
|
||
assert_eq!(data["total"], 6);
|
||
let pts = data["points"].as_array().unwrap();
|
||
assert_eq!(pts.len(), 6);
|
||
assert_eq!(
|
||
pts.first().unwrap()["name"],
|
||
n.pending,
|
||
"最低 teff 应排首位"
|
||
);
|
||
assert_eq!(pts.last().unwrap()["name"], n.imported, "wave=1 应排末位");
|
||
|
||
// ---- 3. 状态过滤:failed 仅 1 个 ----
|
||
let (st, data) = get_points(&app, "/api/workflows/wf_pts/points?status=failed").await;
|
||
assert_eq!(st, StatusCode::OK);
|
||
assert_eq!(data["total"], 1);
|
||
assert_eq!(data["points"][0]["name"], n.failed);
|
||
|
||
// ---- 4. 手段过滤:seed_step 收敛 2 个(seed + rescued) ----
|
||
let (st, data) = get_points(&app, "/api/workflows/wf_pts/points?method=seed_step").await;
|
||
assert_eq!(st, StatusCode::OK);
|
||
assert_eq!(data["total"], 2);
|
||
let names: Vec<&str> = data["points"]
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.map(|p| p["name"].as_str().unwrap())
|
||
.collect();
|
||
assert!(names.contains(&n.seed.as_str()));
|
||
assert!(names.contains(&n.rescued.as_str()));
|
||
|
||
// ---- 5. 点名搜索 ----
|
||
let (st, data) = get_points(&app, "/api/workflows/wf_pts/points?q=t35000").await;
|
||
assert_eq!(st, StatusCode::OK);
|
||
assert_eq!(data["total"], 1);
|
||
assert_eq!(data["points"][0]["name"], n.failed);
|
||
|
||
// ---- 6. wave 过滤 ----
|
||
let (st, data) = get_points(&app, "/api/workflows/wf_pts/points?wave=1").await;
|
||
assert_eq!(st, StatusCode::OK);
|
||
assert_eq!(data["total"], 1);
|
||
assert_eq!(data["points"][0]["name"], n.imported);
|
||
|
||
// ---- 7. 分页:limit=2 翻页,total 恒定;limit 缺省=全量(无上限,联合分析用) ----
|
||
let (_, page0) = get_points(&app, "/api/workflows/wf_pts/points?limit=2&offset=0").await;
|
||
let (_, page2) = get_points(&app, "/api/workflows/wf_pts/points?limit=2&offset=4").await;
|
||
assert_eq!(page0["total"], 6);
|
||
assert_eq!(page0["points"].as_array().unwrap().len(), 2);
|
||
assert_eq!(page2["points"].as_array().unwrap().len(), 2);
|
||
let (_, all) = get_points(&app, "/api/workflows/wf_pts/points?limit=9999").await;
|
||
assert_eq!(all["points"].as_array().unwrap().len(), 6);
|
||
// limit 完全缺省 → 全量(None 路径,不拼 LIMIT 子句)
|
||
let (_, full) = get_points(&app, "/api/workflows/wf_pts/points").await;
|
||
assert_eq!(full["points"].as_array().unwrap().len(), 6);
|
||
|
||
// ---- 8. 时间排序:最近完成的点(rescued 的 seed_step 尝试最新)排首位 ----
|
||
let (st, data) = get_points(
|
||
&app,
|
||
"/api/workflows/wf_pts/points?sort=last_completed_at&order=desc",
|
||
)
|
||
.await;
|
||
assert_eq!(st, StatusCode::OK);
|
||
assert_eq!(data["points"][0]["name"], n.rescued);
|
||
|
||
// ---- 9. 非法枚举 → 400 ----
|
||
let (st, _) = get_points(&app, "/api/workflows/wf_pts/points?status=bogus").await;
|
||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||
let (st, _) = get_points(&app, "/api/workflows/wf_pts/points?sort=bogus").await;
|
||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||
|
||
// ---- 10. 最近尝试 JOIN:rescued 双尝试取最新(seed_step 救回) ----
|
||
let (_, data) = get_points(&app, "/api/workflows/wf_pts/points").await;
|
||
let rescued = find_point(&data, &n.rescued);
|
||
assert_eq!(rescued["status"], "completed");
|
||
assert_eq!(rescued["tlusty_success_method"], "seed_step");
|
||
assert_eq!(rescued["attempt_count"], 2, "两次尝试都应计数");
|
||
assert_eq!(rescued["seed_point_name"], n.cold, "种子来源应为 cold 点");
|
||
assert_eq!(rescued["last_max_relc"], 0.0005);
|
||
assert_eq!(rescued["last_elapsed_sec"], 120.0, "真实墙钟耗时应落库");
|
||
// pending 点无任何尝试 → last_* 全 null
|
||
let pending = find_point(&data, &n.pending);
|
||
assert!(pending["last_completed_at"].is_null());
|
||
assert!(pending["last_elapsed_sec"].is_null());
|
||
assert_eq!(pending["attempt_count"], 0);
|
||
|
||
// ---- 11. 空工作流与未知工作流 ----
|
||
let (st, data) = get_points(&app, "/api/workflows/wf_pts_empty/points").await;
|
||
assert_eq!(st, StatusCode::OK);
|
||
assert_eq!(data["total"], 0);
|
||
assert!(data["points"].as_array().unwrap().is_empty());
|
||
let (st, _) = get_points(&app, "/api/workflows/nonexistent/points").await;
|
||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||
}
|
||
|
||
/// GET /api/workflows/:name/points/:point:尝试历史 + conv.json 解析、
|
||
/// conv 缺失降级 null、路径穿越 400、未知点 404。
|
||
#[tokio::test]
|
||
async fn test_point_detail_endpoint() {
|
||
let temp_dir = tempfile::tempdir().unwrap();
|
||
let db_path = temp_dir.path().join("wfpd_db.db");
|
||
let queue_db_path = temp_dir.path().join("wfpd_queue.db");
|
||
let seeds_dir = temp_dir.path().join("results");
|
||
std::fs::create_dir_all(&seeds_dir).unwrap();
|
||
|
||
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
|
||
let queue = Arc::new(
|
||
SqliteTaskQueue::new(&queue_db_path.to_string_lossy())
|
||
.await
|
||
.unwrap(),
|
||
);
|
||
let scheduler = Arc::new(GridScheduler::new(db.clone(), queue.clone()));
|
||
|
||
let state = AppState {
|
||
db: db.clone(),
|
||
queue,
|
||
scheduler,
|
||
seeds_dir: seeds_dir.to_string_lossy().to_string(),
|
||
rate_limiter: server::api::rate_limit::RateLimiter::new(
|
||
5,
|
||
std::time::Duration::from_secs(300),
|
||
),
|
||
admin_token: Some("admin-secret".to_string()),
|
||
auth_disabled: false,
|
||
admin_sessions: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
|
||
};
|
||
|
||
let api_router = axum::Router::new().route(
|
||
"/workflows/:name/points/:point",
|
||
axum::routing::get(server::api::workflow::get_workflow_point_detail),
|
||
);
|
||
let auth_layer =
|
||
axum::middleware::from_fn_with_state(state.clone(), server::api::auth_middleware);
|
||
let app = axum::Router::new()
|
||
.nest("/api", api_router.layer(auth_layer))
|
||
.with_state(state);
|
||
|
||
let n = seed_obs_fixture(&db, &db_path, "wf_pd").await;
|
||
|
||
// conv 诊断面板数据源已改为 grid_points.summary_json(dispatch_and_report 写入),
|
||
// 不再需要磁盘 conv.json。rescued 点最终 seed_step 成功 → 也有 summary_json,
|
||
// 但此处验证 null 降级用 pending 点(未 report,无 summary)。
|
||
|
||
async fn get_detail(app: &axum::Router, uri: &str) -> (StatusCode, serde_json::Value) {
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri(uri)
|
||
.header("authorization", "Bearer admin-secret")
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
let status = res.status();
|
||
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
|
||
.await
|
||
.unwrap();
|
||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||
(status, json["data"].clone())
|
||
}
|
||
|
||
// ---- 1. 无 token → 401 ----
|
||
let uri = format!("/api/workflows/wf_pd/points/{}", n.cold);
|
||
let res = app
|
||
.clone()
|
||
.oneshot(Request::builder().uri(&uri).body(Body::empty()).unwrap())
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
|
||
|
||
// ---- 2. cold 点:点行 + 1 次尝试 + conv 解析成功 ----
|
||
let (st, data) = get_detail(&app, &uri).await;
|
||
assert_eq!(st, StatusCode::OK);
|
||
assert_eq!(data["point"]["name"], n.cold);
|
||
assert_eq!(data["point"]["status"], "completed");
|
||
assert_eq!(data["point"]["tlusty_success_method"], "cold_run");
|
||
let attempts = data["attempts"].as_array().unwrap();
|
||
assert_eq!(attempts.len(), 1);
|
||
assert!(attempts[0]["seed_point_name"].is_null(), "冷启动无种子来源");
|
||
assert_eq!(attempts[0]["status"], "completed");
|
||
assert_eq!(data["conv"]["result_valid"], true, "summary_json 应被解析");
|
||
assert_eq!(data["conv"]["final_max_relc"], 0.0005);
|
||
|
||
// ---- 3. rescued 点:2 次尝试按时间升序(冷启失败 → 种子步进救回)----
|
||
// 最终 seed_step 成功 → record_task_report 写入 summary_json,conv 非 null。
|
||
let uri = format!("/api/workflows/wf_pd/points/{}", n.rescued);
|
||
let (st, data) = get_detail(&app, &uri).await;
|
||
assert_eq!(st, StatusCode::OK);
|
||
assert_eq!(data["point"]["attempt_count"], 2);
|
||
let attempts = data["attempts"].as_array().unwrap();
|
||
assert_eq!(attempts.len(), 2);
|
||
assert!(attempts[0]["seed_point_name"].is_null(), "首次冷启动无种子");
|
||
assert_eq!(attempts[0]["status"], "failed");
|
||
assert_eq!(attempts[0]["elapsed_sec"], 120.0, "每次尝试耗时应落库");
|
||
assert_eq!(attempts[1]["status"], "completed");
|
||
assert_eq!(
|
||
attempts[1]["seed_point_name"], n.cold,
|
||
"种子来源应为 cold 点"
|
||
);
|
||
assert_eq!(data["conv"]["result_valid"], true, "rescued 最终成功应有 summary");
|
||
|
||
// ---- 3b. pending 点:从未 report → 无 summary_json → conv 为 null(降级不报错)----
|
||
let uri = format!("/api/workflows/wf_pd/points/{}", n.pending);
|
||
let (st, data) = get_detail(&app, &uri).await;
|
||
assert_eq!(st, StatusCode::OK);
|
||
assert!(data["conv"].is_null(), "未结算点无 summary 应返回 null");
|
||
|
||
// ---- 4. 路径穿越 / 非法字符 → 400 ----
|
||
for bad in ["..%2Fevil", "a%2Fb", ".hidden", ".."] {
|
||
let uri = format!("/api/workflows/wf_pd/points/{}", bad);
|
||
let (st, _) = get_detail(&app, &uri).await;
|
||
assert_eq!(st, StatusCode::BAD_REQUEST, "点名 {} 应被拒绝", bad);
|
||
}
|
||
|
||
// ---- 5. 未知点 → 404 ----
|
||
let (st, _) = get_detail(
|
||
&app,
|
||
"/api/workflows/wf_pd/points/t99999_g5.0_he-2_c-4_n-4_o-4",
|
||
)
|
||
.await;
|
||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||
}
|
||
|
||
/// GET /api/workflows:每工作流内联网格统计——有点的给 stats 对象,无点的为 null。
|
||
#[tokio::test]
|
||
async fn test_list_workflows_inline_stats() {
|
||
let temp_dir = tempfile::tempdir().unwrap();
|
||
let db_path = temp_dir.path().join("wflist_db.db");
|
||
let queue_db_path = temp_dir.path().join("wflist_queue.db");
|
||
let seeds_dir = temp_dir.path().join("results");
|
||
std::fs::create_dir_all(&seeds_dir).unwrap();
|
||
|
||
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
|
||
let queue = Arc::new(
|
||
SqliteTaskQueue::new(&queue_db_path.to_string_lossy())
|
||
.await
|
||
.unwrap(),
|
||
);
|
||
let scheduler = Arc::new(GridScheduler::new(db.clone(), queue.clone()));
|
||
|
||
let state = AppState {
|
||
db: db.clone(),
|
||
queue,
|
||
scheduler,
|
||
seeds_dir: seeds_dir.to_string_lossy().to_string(),
|
||
rate_limiter: server::api::rate_limit::RateLimiter::new(
|
||
5,
|
||
std::time::Duration::from_secs(300),
|
||
),
|
||
admin_token: Some("admin-secret".to_string()),
|
||
auth_disabled: false,
|
||
admin_sessions: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
|
||
};
|
||
|
||
let api_router = axum::Router::new().route(
|
||
"/workflows",
|
||
axum::routing::get(server::api::workflow::list_workflows),
|
||
);
|
||
let auth_layer =
|
||
axum::middleware::from_fn_with_state(state.clone(), server::api::auth_middleware);
|
||
let app = axum::Router::new()
|
||
.nest("/api", api_router.layer(auth_layer))
|
||
.with_state(state);
|
||
|
||
// wf_list_a 有 2 点(1 cold_run 收敛 + 1 pending);wf_list_b 无点
|
||
let yaml = "grid:\n teff: [20000]\n logg: [5.0]\n loghe: [-2]\n logc: [-4]\n logn: [-4]\n logo: [-4]";
|
||
db.upsert_workflow("wf_list_a", None, yaml, "running")
|
||
.await
|
||
.unwrap();
|
||
db.upsert_workflow("wf_list_b", None, yaml, "idle")
|
||
.await
|
||
.unwrap();
|
||
let p1 = wf_stats_test_params(20000.0, -4.0);
|
||
let p2 = wf_stats_test_params(25000.0, -4.0);
|
||
db.upsert_grid_point(&p1, 0, "wf_list_a").await.unwrap();
|
||
db.upsert_grid_point(&p2, 0, "wf_list_a").await.unwrap();
|
||
dispatch_and_report(&db, "wf_list_a", &p1, "cold_run", None, true).await;
|
||
|
||
// 无 token → 401
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri("/api/workflows")
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
|
||
|
||
// 带 token → 200,stats 内联(两工作流一次返回,无 N+1)
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri("/api/workflows")
|
||
.header("authorization", "Bearer admin-secret")
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::OK);
|
||
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
|
||
.await
|
||
.unwrap();
|
||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||
let list = json["data"].as_array().unwrap();
|
||
let wf_a = list.iter().find(|w| w["name"] == "wf_list_a").unwrap();
|
||
let wf_b = list.iter().find(|w| w["name"] == "wf_list_b").unwrap();
|
||
|
||
assert_eq!(wf_a["stats"]["total"], 2);
|
||
assert_eq!(wf_a["stats"]["completed"], 1);
|
||
assert_eq!(wf_a["stats"]["cold_run_converged"], 1);
|
||
assert_eq!(wf_a["stats"]["failed"], 0);
|
||
assert_eq!(wf_a["stats"]["running"], 0);
|
||
assert!(wf_b["stats"].is_null(), "无网格点的工作流 stats 应为 null");
|
||
}
|
||
|
||
/// GET /api/workflows/:name/progress:快照去重写入、经验速率、停滞时长、保留期清理、鉴权。
|
||
#[tokio::test]
|
||
async fn test_wf_progress_endpoint() {
|
||
let temp_dir = tempfile::tempdir().unwrap();
|
||
let db_path = temp_dir.path().join("wfprog_db.db");
|
||
let queue_db_path = temp_dir.path().join("wfprog_queue.db");
|
||
let seeds_dir = temp_dir.path().join("results");
|
||
std::fs::create_dir_all(&seeds_dir).unwrap();
|
||
|
||
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
|
||
let queue = Arc::new(
|
||
SqliteTaskQueue::new(&queue_db_path.to_string_lossy())
|
||
.await
|
||
.unwrap(),
|
||
);
|
||
let scheduler = Arc::new(GridScheduler::new(db.clone(), queue.clone()));
|
||
|
||
let state = AppState {
|
||
db: db.clone(),
|
||
queue,
|
||
scheduler,
|
||
seeds_dir: seeds_dir.to_string_lossy().to_string(),
|
||
rate_limiter: server::api::rate_limit::RateLimiter::new(
|
||
5,
|
||
std::time::Duration::from_secs(300),
|
||
),
|
||
admin_token: Some("admin-secret".to_string()),
|
||
auth_disabled: false,
|
||
admin_sessions: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
|
||
};
|
||
|
||
let api_router = axum::Router::new().route(
|
||
"/workflows/:name/progress",
|
||
axum::routing::get(server::api::workflow::get_workflow_progress),
|
||
);
|
||
let auth_layer =
|
||
axum::middleware::from_fn_with_state(state.clone(), server::api::auth_middleware);
|
||
let app = axum::Router::new()
|
||
.nest("/api", api_router.layer(auth_layer))
|
||
.with_state(state);
|
||
|
||
// ---- 快照写入与去重 ----
|
||
let yaml = "grid:\n teff: [20000]\n logg: [5.0]\n loghe: [-2]\n logc: [-4]\n logn: [-4]\n logo: [-4]";
|
||
db.upsert_workflow("wf_prog", None, yaml, "running")
|
||
.await
|
||
.unwrap();
|
||
let p1 = wf_stats_test_params(20000.0, -4.0);
|
||
let p2 = wf_stats_test_params(25000.0, -4.0);
|
||
db.upsert_grid_point(&p1, 0, "wf_prog").await.unwrap();
|
||
db.upsert_grid_point(&p2, 0, "wf_prog").await.unwrap();
|
||
|
||
assert!(
|
||
db.record_progress_snapshot("wf_prog").await.unwrap(),
|
||
"首次记录应写入"
|
||
);
|
||
dispatch_and_report(&db, "wf_prog", &p1, "cold_run", None, true).await;
|
||
assert!(
|
||
db.record_progress_snapshot("wf_prog").await.unwrap(),
|
||
"计数变化应写入"
|
||
);
|
||
assert!(
|
||
!db.record_progress_snapshot("wf_prog").await.unwrap(),
|
||
"计数未变应去重"
|
||
);
|
||
|
||
// 首条快照回拨 2 小时 → 经验速率 ≈ 0.5 点/小时(1 点收敛 / 2h)
|
||
{
|
||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||
conn.execute(
|
||
"UPDATE workflow_progress_snapshots SET ts = datetime('now', '-2 hours') \
|
||
WHERE workflow_name = 'wf_prog' AND completed = 0",
|
||
[],
|
||
)
|
||
.unwrap();
|
||
}
|
||
|
||
// ---- 无 token → 401 ----
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri("/api/workflows/wf_prog/progress")
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
|
||
|
||
// ---- 带 token → 200:序列 2 条、速率 ≈0.5、停滞 ≈0(末条即最新增长) ----
|
||
let res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri("/api/workflows/wf_prog/progress")
|
||
.header("authorization", "Bearer admin-secret")
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::OK);
|
||
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
|
||
.await
|
||
.unwrap();
|
||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||
let data = &json["data"];
|
||
assert_eq!(data["hours"], 24);
|
||
assert_eq!(data["series"].as_array().unwrap().len(), 2);
|
||
let rate = data["rate_per_hour"].as_f64().expect("应有经验速率");
|
||
assert!((rate - 0.5).abs() < 0.05, "速率应 ≈ 0.5 点/小时");
|
||
let stalled = data["stalled_minutes"].as_f64().expect("应有停滞时长");
|
||
assert!(stalled < 1.0, "终态刚增长,停滞应 ≈ 0");
|
||
|
||
// ---- 保留期清理:回拨一条 -10 天 → purge(7) 后仅剩 1 条 ----
|
||
{
|
||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||
conn.execute(
|
||
"UPDATE workflow_progress_snapshots SET ts = datetime('now', '-10 days') \
|
||
WHERE workflow_name = 'wf_prog' AND completed = 0",
|
||
[],
|
||
)
|
||
.unwrap();
|
||
}
|
||
db.purge_progress_snapshots(7).await.unwrap();
|
||
let series = db.get_progress_series("wf_prog", 168).await.unwrap();
|
||
assert_eq!(series.len(), 1, "超 7 天的快照应被清理");
|
||
|
||
// ---- 未知工作流 → 404 ----
|
||
let res = app
|
||
.oneshot(
|
||
Request::builder()
|
||
.uri("/api/workflows/nonexistent/progress")
|
||
.header("authorization", "Bearer admin-secret")
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(res.status(), StatusCode::NOT_FOUND);
|
||
}
|
||
|
||
/// 构造仅含 report 字段的 multipart body(模拟节点上报,不带 seed_file)。
|
||
fn make_report_only_multipart(boundary: &str, report_json: &str) -> Vec<u8> {
|
||
let mut body = Vec::new();
|
||
body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes());
|
||
body.extend_from_slice(b"Content-Disposition: form-data; name=\"report\"\r\n");
|
||
body.extend_from_slice(b"Content-Type: application/json\r\n\r\n");
|
||
body.extend_from_slice(report_json.as_bytes());
|
||
body.extend_from_slice(b"\r\n");
|
||
body.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes());
|
||
body
|
||
}
|
||
|
||
/// 端到端回归(2026-08-02 涡旋事故):已收敛点收到迟到的重复失败报告时,
|
||
/// 走完整 HTTP 链路(claim → report)后状态保持 converged,且不触发种子回退
|
||
/// (state_changed=false 跳过 fallback;纵有可用种子也不产生 seed_step 任务)。
|
||
#[tokio::test]
|
||
async fn test_duplicate_failure_report_cannot_flip_converged() {
|
||
let temp_dir = tempfile::tempdir().unwrap();
|
||
let db_path = temp_dir.path().join("flip_db.db");
|
||
let queue_db_path = temp_dir.path().join("flip_queue.db");
|
||
let seeds_dir = temp_dir.path().join("results");
|
||
std::fs::create_dir_all(&seeds_dir).unwrap();
|
||
|
||
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
|
||
let queue = Arc::new(
|
||
SqliteTaskQueue::new(&queue_db_path.to_string_lossy())
|
||
.await
|
||
.unwrap(),
|
||
);
|
||
let scheduler = Arc::new(GridScheduler::new(db.clone(), queue.clone()));
|
||
|
||
// 预置节点与 token。
|
||
let reg = common::models::NodeRegisterRequest {
|
||
node_id: "node-flip".to_string(),
|
||
max_slots: 2,
|
||
};
|
||
db.register_node(®).await.unwrap();
|
||
let token = db.issue_node_token("node-flip").await.unwrap();
|
||
|
||
let state = AppState {
|
||
db: db.clone(),
|
||
queue: queue.clone(),
|
||
scheduler,
|
||
seeds_dir: seeds_dir.to_string_lossy().to_string(),
|
||
rate_limiter: server::api::rate_limit::RateLimiter::new(
|
||
100,
|
||
std::time::Duration::from_secs(300),
|
||
),
|
||
admin_token: None,
|
||
auth_disabled: false,
|
||
admin_sessions: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
|
||
};
|
||
let api_router = axum::Router::new()
|
||
.route(
|
||
"/task/claim",
|
||
axum::routing::post(server::api::task::claim_task),
|
||
)
|
||
.route(
|
||
"/task/report",
|
||
axum::routing::post(server::api::task::report_task),
|
||
);
|
||
let auth_layer =
|
||
axum::middleware::from_fn_with_state(state.clone(), server::api::auth_middleware);
|
||
let app = axum::Router::new()
|
||
.nest("/api", api_router.layer(auth_layer))
|
||
.with_state(state);
|
||
|
||
// 工作流 running + 网格点 + 可用种子(若回退被错误触发,必然能匹配到种子并派发任务,
|
||
// 使"无 seed_step 任务"断言成为强证据)。
|
||
db.upsert_workflow("wf_flip", None, "", "running")
|
||
.await
|
||
.unwrap();
|
||
let params = common::models::GridPointParams {
|
||
teff: 35000.0.into(),
|
||
logg: 5.5.into(),
|
||
loghe: (-1.0).into(),
|
||
logc: (-2.0).into(),
|
||
logn: (-2.0).into(),
|
||
logo: (-2.0).into(),
|
||
};
|
||
let name = params.model_name();
|
||
db.upsert_grid_point(¶ms, 0, "wf_flip").await.unwrap();
|
||
db.insert_seed_named(&name, ¶ms, "/tmp/flip_seed.7")
|
||
.await
|
||
.unwrap();
|
||
|
||
// ---- 任务 A:正常收敛 ----
|
||
let task_a = common::models::TaskSpec {
|
||
task_id: uuid::Uuid::new_v4(),
|
||
point_name: name.clone(),
|
||
params: params.clone(),
|
||
seed_point_name: None,
|
||
timeout_sec: 7200,
|
||
workflow_name: Some("wf_flip".to_string()),
|
||
wave: 0,
|
||
..Default::default()
|
||
};
|
||
db.insert_task(&task_a).await.unwrap();
|
||
queue.push_task(&task_a).await.unwrap();
|
||
|
||
let claim_res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/task/claim")
|
||
.header("authorization", format!("Bearer {}", token))
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(claim_res.status(), StatusCode::OK);
|
||
|
||
let report_a = common::models::TaskReport {
|
||
task_id: task_a.task_id,
|
||
point_name: name.clone(),
|
||
params: Some(params.clone()),
|
||
node_id: "node-flip".to_string(),
|
||
status: common::models::TaskStatus::Completed,
|
||
result_valid: true,
|
||
max_relc: Some(0.0005),
|
||
atmosphere_has_nan: false,
|
||
elapsed_sec: 120.0,
|
||
error_message: None,
|
||
summary_json: "{}".to_string(),
|
||
failed_stage: None,
|
||
};
|
||
let body_a =
|
||
make_report_only_multipart("flipbound1", &serde_json::to_string(&report_a).unwrap());
|
||
let report_res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/task/report")
|
||
.header("authorization", format!("Bearer {}", token))
|
||
.header("content-type", "multipart/form-data; boundary=flipbound1")
|
||
.body(Body::from(body_a))
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(report_res.status(), StatusCode::OK);
|
||
assert_eq!(
|
||
db.get_grid_point_status(&name, "wf_flip")
|
||
.await
|
||
.unwrap()
|
||
.unwrap()
|
||
.0,
|
||
"completed"
|
||
);
|
||
|
||
// ---- 任务 B:迟到的重复失败报告(涡旋残留任务的典型行为)----
|
||
let task_b = common::models::TaskSpec {
|
||
task_id: uuid::Uuid::new_v4(),
|
||
point_name: name.clone(),
|
||
params: params.clone(),
|
||
seed_point_name: None,
|
||
timeout_sec: 7200,
|
||
workflow_name: Some("wf_flip".to_string()),
|
||
wave: 0,
|
||
..Default::default()
|
||
};
|
||
db.insert_task(&task_b).await.unwrap();
|
||
queue.push_task(&task_b).await.unwrap();
|
||
|
||
let claim_b = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/task/claim")
|
||
.header("authorization", format!("Bearer {}", token))
|
||
.body(Body::empty())
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(claim_b.status(), StatusCode::OK);
|
||
|
||
let report_b = common::models::TaskReport {
|
||
task_id: task_b.task_id,
|
||
point_name: name.clone(),
|
||
params: Some(params.clone()),
|
||
node_id: "node-flip".to_string(),
|
||
status: common::models::TaskStatus::Failed,
|
||
result_valid: false,
|
||
max_relc: Some(9.5e5),
|
||
atmosphere_has_nan: false,
|
||
elapsed_sec: 130.0,
|
||
error_message: Some("nl stage diverged".to_string()),
|
||
summary_json: "{}".to_string(),
|
||
failed_stage: None,
|
||
};
|
||
let body_b =
|
||
make_report_only_multipart("flipbound2", &serde_json::to_string(&report_b).unwrap());
|
||
let report_b_res = app
|
||
.clone()
|
||
.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/task/report")
|
||
.header("authorization", format!("Bearer {}", token))
|
||
.header("content-type", "multipart/form-data; boundary=flipbound2")
|
||
.body(Body::from(body_b))
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(
|
||
report_b_res.status(),
|
||
StatusCode::OK,
|
||
"上报本身应成功(吸收)"
|
||
);
|
||
|
||
// 核心断言:converged 不被翻黑,且未触发任何种子回退任务。
|
||
assert_eq!(
|
||
db.get_grid_point_status(&name, "wf_flip")
|
||
.await
|
||
.unwrap()
|
||
.unwrap()
|
||
.0,
|
||
"completed",
|
||
"迟到失败报告不得翻黑 converged 点"
|
||
);
|
||
assert!(
|
||
db.has_pending_tasks_for_point(&name, "wf_flip", Some("seed_step"))
|
||
.await
|
||
.unwrap()
|
||
.is_empty(),
|
||
"不得因迟到失败报告派发 seed_step"
|
||
);
|
||
assert!(
|
||
!db.has_seed_step_attempt(&name, "wf_flip").await.unwrap(),
|
||
"全程不应产生任何 seed_step 行"
|
||
);
|
||
}
|
||
|
||
/// save_workflow 阶段配置合法性校验(修复审查 #4/#5):
|
||
/// - 双阶段全关 → 400(无任何计算可执行);
|
||
/// - 启用阶段空策略链 → 400(调度无顺位可派,任务必失败);
|
||
/// - 合法配置(synspec-only 场景 B / 默认双开)→ 200。
|
||
#[tokio::test]
|
||
async fn test_save_workflow_validates_stage_configs() {
|
||
let temp_dir = tempfile::tempdir().unwrap();
|
||
let db_path = temp_dir.path().join("wfval_db.db");
|
||
let queue_db_path = temp_dir.path().join("wfval_queue.db");
|
||
let seeds_dir = temp_dir.path().join("results");
|
||
std::fs::create_dir_all(&seeds_dir).unwrap();
|
||
|
||
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
|
||
let queue = Arc::new(
|
||
SqliteTaskQueue::new(&queue_db_path.to_string_lossy())
|
||
.await
|
||
.unwrap(),
|
||
);
|
||
let scheduler = Arc::new(GridScheduler::new(db.clone(), queue.clone()));
|
||
let state = AppState {
|
||
db: db.clone(),
|
||
queue,
|
||
scheduler,
|
||
seeds_dir: seeds_dir.to_string_lossy().to_string(),
|
||
rate_limiter: server::api::rate_limit::RateLimiter::new(
|
||
5,
|
||
std::time::Duration::from_secs(300),
|
||
),
|
||
admin_token: Some("admin-secret".to_string()),
|
||
auth_disabled: false,
|
||
admin_sessions: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
|
||
};
|
||
let app = axum::Router::new()
|
||
.route(
|
||
"/api/workflows",
|
||
axum::routing::post(server::api::workflow::save_workflow),
|
||
)
|
||
.with_state(state);
|
||
|
||
let base_yaml = "grid:\n teff: [35000]\n logg: [5.5]\n loghe: [-1]\n logc: [-2]\n logn: [-2]\n logo: [-2]";
|
||
let post_save = |name: &str, yaml: &str| {
|
||
let app = app.clone();
|
||
let body = serde_json::json!({
|
||
"name": name,
|
||
"description": null,
|
||
"config_yaml": yaml,
|
||
});
|
||
async move {
|
||
app.oneshot(
|
||
Request::builder()
|
||
.method("POST")
|
||
.uri("/api/workflows")
|
||
.header("content-type", "application/json")
|
||
.body(Body::from(serde_json::to_vec(&body).unwrap()))
|
||
.unwrap(),
|
||
)
|
||
.await
|
||
.unwrap()
|
||
}
|
||
};
|
||
|
||
// 1. 双阶段全关 → 400
|
||
let both_off = format!(
|
||
"{}\ntlusty_stage:\n enabled: false\n policy: skip_converged\n strategies: [cold_run, seed_step]\n\
|
||
synspec_stage:\n enabled: false\n policy: skip_converged\n strategies: [standard]\n",
|
||
base_yaml
|
||
);
|
||
let res = post_save("wf_val_a", &both_off).await;
|
||
assert_eq!(res.status(), StatusCode::BAD_REQUEST, "双阶段全关应 400");
|
||
|
||
// 2. 启用阶段空策略链 → 400
|
||
let empty_chain = format!(
|
||
"{}\ntlusty_stage:\n enabled: true\n policy: skip_converged\n strategies: []\n",
|
||
base_yaml
|
||
);
|
||
let res = post_save("wf_val_b", &empty_chain).await;
|
||
assert_eq!(
|
||
res.status(),
|
||
StatusCode::BAD_REQUEST,
|
||
"启用阶段空策略链应 400"
|
||
);
|
||
|
||
// 3. 合法:TLUSTY 关 + SYNSPEC 启(设计 §2.2 场景 B:仅更新光谱)→ 200
|
||
let syn_only = format!(
|
||
"{}\ntlusty_stage:\n enabled: false\n policy: skip_converged\n strategies: [cold_run, seed_step]\n\
|
||
synspec_stage:\n enabled: true\n policy: force_recompute\n strategies: [standard]\n",
|
||
base_yaml
|
||
);
|
||
let res = post_save("wf_val_c", &syn_only).await;
|
||
assert_eq!(res.status(), StatusCode::OK, "synspec-only 合法配置应 200");
|
||
|
||
// 4. 合法:无阶段块(默认双开)→ 200
|
||
let res = post_save("wf_val_d", base_yaml).await;
|
||
assert_eq!(res.status(), StatusCode::OK, "默认配置应 200");
|
||
}
|