P0 安全修复: - 修复 account update 自角色提升漏洞: 非 admin 用户更新自己时剥离 role 字段 - 添加 Admin 引导机制: accounts 表为空时自动从环境变量创建 super_admin P1 功能补全: - 所有 17 个 log_operation 调用点传入真实客户端 IP (ConnectInfo + X-Forwarded-For) - AuthContext 新增 client_ip 字段, middleware 层自动提取 - main.rs 使用 into_make_service_with_connect_info 启用 SocketAddr 注入 - 新增 PUT /api/v1/auth/password 密码修改端点 (验证旧密码 + argon2 哈希) - 桌面端 SaaS 设置页添加密码修改 UI (折叠式表单) - SaaSClient 添加 changePassword() 方法 - 集成测试修复: 注入模拟 ConnectInfo 适配 onshot 测试模式
456 lines
15 KiB
Rust
456 lines
15 KiB
Rust
//! 集成测试 (Phase 1 + Phase 2)
|
|
|
|
use axum::{
|
|
body::Body,
|
|
http::{Request, StatusCode},
|
|
};
|
|
use serde_json::json;
|
|
use tower::ServiceExt;
|
|
|
|
const MAX_BODY_SIZE: usize = 1024 * 1024; // 1MB
|
|
|
|
async fn build_test_app() -> axum::Router {
|
|
use zclaw_saas::{config::SaaSConfig, db::init_memory_db, state::AppState};
|
|
use axum::extract::ConnectInfo;
|
|
use std::net::SocketAddr;
|
|
|
|
// 测试环境设置开发模式 (允许 http、默认 JWT secret)
|
|
std::env::set_var("ZCLAW_SAAS_DEV", "true");
|
|
std::env::set_var("ZCLAW_SAAS_JWT_SECRET", "test-secret-for-integration-tests-only");
|
|
|
|
let db = init_memory_db().await.unwrap();
|
|
let mut config = SaaSConfig::default();
|
|
config.auth.jwt_expiration_hours = 24;
|
|
let state = AppState::new(db, config).expect("测试环境 AppState 初始化失败");
|
|
|
|
let public_routes = zclaw_saas::auth::routes();
|
|
|
|
let protected_routes = zclaw_saas::auth::protected_routes()
|
|
.merge(zclaw_saas::account::routes())
|
|
.merge(zclaw_saas::model_config::routes())
|
|
.merge(zclaw_saas::relay::routes())
|
|
.merge(zclaw_saas::migration::routes())
|
|
.layer(axum::middleware::from_fn_with_state(
|
|
state.clone(),
|
|
zclaw_saas::auth::auth_middleware,
|
|
));
|
|
|
|
axum::Router::new()
|
|
.merge(public_routes)
|
|
.merge(protected_routes)
|
|
.with_state(state)
|
|
.layer(axum::middleware::from_fn(
|
|
|mut req: axum::extract::Request, next: axum::middleware::Next| async move {
|
|
req.extensions_mut().insert(ConnectInfo::<SocketAddr>(
|
|
"127.0.0.1:0".parse().unwrap(),
|
|
));
|
|
next.run(req).await
|
|
},
|
|
))
|
|
}
|
|
|
|
/// 注册并登录,返回 JWT token
|
|
async fn register_and_login(app: &axum::Router, username: &str, email: &str) -> String {
|
|
let register_req = Request::builder()
|
|
.method("POST")
|
|
.uri("/api/v1/auth/register")
|
|
.header("Content-Type", "application/json")
|
|
.body(Body::from(serde_json::to_string(&json!({
|
|
"username": username,
|
|
"email": email,
|
|
"password": "password123"
|
|
})).unwrap()))
|
|
.unwrap();
|
|
app.clone().oneshot(register_req).await.unwrap();
|
|
|
|
let login_req = Request::builder()
|
|
.method("POST")
|
|
.uri("/api/v1/auth/login")
|
|
.header("Content-Type", "application/json")
|
|
.body(Body::from(serde_json::to_string(&json!({
|
|
"username": username,
|
|
"password": "password123"
|
|
})).unwrap()))
|
|
.unwrap();
|
|
|
|
let resp = app.clone().oneshot(login_req).await.unwrap();
|
|
let body_bytes = axum::body::to_bytes(resp.into_body(), MAX_BODY_SIZE).await.unwrap();
|
|
let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
|
|
body["token"].as_str().unwrap().to_string()
|
|
}
|
|
|
|
fn auth_header(token: &str) -> String {
|
|
format!("Bearer {}", token)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_register_and_login() {
|
|
let app = build_test_app().await;
|
|
let token = register_and_login(&app, "testuser", "test@example.com").await;
|
|
assert!(!token.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_register_duplicate_fails() {
|
|
let app = build_test_app().await;
|
|
|
|
let body = json!({
|
|
"username": "dupuser",
|
|
"email": "dup@example.com",
|
|
"password": "password123"
|
|
});
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri("/api/v1/auth/register")
|
|
.header("Content-Type", "application/json")
|
|
.body(Body::from(serde_json::to_string(&body).unwrap()))
|
|
.unwrap();
|
|
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri("/api/v1/auth/register")
|
|
.header("Content-Type", "application/json")
|
|
.body(Body::from(serde_json::to_string(&body).unwrap()))
|
|
.unwrap();
|
|
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CONFLICT);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_unauthorized_access() {
|
|
let app = build_test_app().await;
|
|
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri("/api/v1/accounts")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_login_wrong_password() {
|
|
let app = build_test_app().await;
|
|
register_and_login(&app, "wrongpwd", "wrongpwd@example.com").await;
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri("/api/v1/auth/login")
|
|
.header("Content-Type", "application/json")
|
|
.body(Body::from(serde_json::to_string(&json!({
|
|
"username": "wrongpwd",
|
|
"password": "wrong_password"
|
|
})).unwrap()))
|
|
.unwrap();
|
|
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_full_authenticated_flow() {
|
|
let app = build_test_app().await;
|
|
let token = register_and_login(&app, "fulltest", "full@example.com").await;
|
|
|
|
// 创建 API Token
|
|
let create_token_req = Request::builder()
|
|
.method("POST")
|
|
.uri("/api/v1/tokens")
|
|
.header("Content-Type", "application/json")
|
|
.header("Authorization", auth_header(&token))
|
|
.body(Body::from(serde_json::to_string(&json!({
|
|
"name": "test-token",
|
|
"permissions": ["model:read", "relay:use"]
|
|
})).unwrap()))
|
|
.unwrap();
|
|
|
|
let resp = app.clone().oneshot(create_token_req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body_bytes = axum::body::to_bytes(resp.into_body(), MAX_BODY_SIZE).await.unwrap();
|
|
let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
|
|
assert!(!body["token"].is_null());
|
|
|
|
// 列出 Tokens
|
|
let list_req = Request::builder()
|
|
.method("GET")
|
|
.uri("/api/v1/tokens")
|
|
.header("Authorization", auth_header(&token))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let resp = app.clone().oneshot(list_req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
// 查看操作日志 (普通用户无 admin 权限 → 403)
|
|
let logs_req = Request::builder()
|
|
.method("GET")
|
|
.uri("/api/v1/logs/operations")
|
|
.header("Authorization", auth_header(&token))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let resp = app.oneshot(logs_req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
// ============ Phase 2: 模型配置测试 ============
|
|
|
|
#[tokio::test]
|
|
async fn test_providers_crud() {
|
|
let app = build_test_app().await;
|
|
// 注册 super_admin 角色用户 (通过直接插入角色权限)
|
|
let token = register_and_login(&app, "adminprov", "adminprov@example.com").await;
|
|
|
|
// 创建 provider (普通用户无权限 → 403)
|
|
let create_req = Request::builder()
|
|
.method("POST")
|
|
.uri("/api/v1/providers")
|
|
.header("Content-Type", "application/json")
|
|
.header("Authorization", auth_header(&token))
|
|
.body(Body::from(serde_json::to_string(&json!({
|
|
"name": "test-provider",
|
|
"display_name": "Test Provider",
|
|
"base_url": "https://api.example.com/v1"
|
|
})).unwrap()))
|
|
.unwrap();
|
|
|
|
let resp = app.clone().oneshot(create_req).await.unwrap();
|
|
// user 角色默认无 provider:manage 权限 → 403
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
|
|
// 列出 providers (只读权限 → 200)
|
|
let list_req = Request::builder()
|
|
.method("GET")
|
|
.uri("/api/v1/providers")
|
|
.header("Authorization", auth_header(&token))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let resp = app.clone().oneshot(list_req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_models_list_and_usage() {
|
|
let app = build_test_app().await;
|
|
let token = register_and_login(&app, "modeluser", "modeluser@example.com").await;
|
|
|
|
// 列出模型 (空列表)
|
|
let list_req = Request::builder()
|
|
.method("GET")
|
|
.uri("/api/v1/models")
|
|
.header("Authorization", auth_header(&token))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let resp = app.clone().oneshot(list_req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body_bytes = axum::body::to_bytes(resp.into_body(), MAX_BODY_SIZE).await.unwrap();
|
|
let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
|
|
assert!(body.is_array());
|
|
assert_eq!(body.as_array().unwrap().len(), 0);
|
|
|
|
// 查看用量统计
|
|
let usage_req = Request::builder()
|
|
.method("GET")
|
|
.uri("/api/v1/usage")
|
|
.header("Authorization", auth_header(&token))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let resp = app.clone().oneshot(usage_req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body_bytes = axum::body::to_bytes(resp.into_body(), MAX_BODY_SIZE).await.unwrap();
|
|
let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
|
|
assert_eq!(body["total_requests"], 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_api_keys_lifecycle() {
|
|
let app = build_test_app().await;
|
|
let token = register_and_login(&app, "keyuser", "keyuser@example.com").await;
|
|
|
|
// 列出 keys (空)
|
|
let list_req = Request::builder()
|
|
.method("GET")
|
|
.uri("/api/v1/keys")
|
|
.header("Authorization", auth_header(&token))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let resp = app.clone().oneshot(list_req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
// 创建 key (需要已有 provider → 404 或由 service 层验证)
|
|
let create_req = Request::builder()
|
|
.method("POST")
|
|
.uri("/api/v1/keys")
|
|
.header("Content-Type", "application/json")
|
|
.header("Authorization", auth_header(&token))
|
|
.body(Body::from(serde_json::to_string(&json!({
|
|
"provider_id": "nonexistent",
|
|
"key_value": "sk-test-12345",
|
|
"key_label": "Test Key"
|
|
})).unwrap()))
|
|
.unwrap();
|
|
|
|
let resp = app.clone().oneshot(create_req).await.unwrap();
|
|
// provider 不存在 → 404
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
// ============ Phase 3: 中转服务测试 ============
|
|
|
|
#[tokio::test]
|
|
async fn test_relay_models_list() {
|
|
let app = build_test_app().await;
|
|
let token = register_and_login(&app, "relayuser", "relayuser@example.com").await;
|
|
|
|
// 列出可用中转模型 (空列表,因为没有 provider/model 种子数据)
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri("/api/v1/relay/models")
|
|
.header("Authorization", auth_header(&token))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body_bytes = axum::body::to_bytes(resp.into_body(), MAX_BODY_SIZE).await.unwrap();
|
|
let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
|
|
assert!(body.is_array());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_relay_chat_no_model() {
|
|
let app = build_test_app().await;
|
|
let token = register_and_login(&app, "relayfail", "relayfail@example.com").await;
|
|
|
|
// 尝试中转到不存在的模型
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri("/api/v1/relay/chat/completions")
|
|
.header("Content-Type", "application/json")
|
|
.header("Authorization", auth_header(&token))
|
|
.body(Body::from(serde_json::to_string(&json!({
|
|
"model": "nonexistent-model",
|
|
"messages": [{"role": "user", "content": "hello"}]
|
|
})).unwrap()))
|
|
.unwrap();
|
|
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
// 模型不存在 → 404
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_relay_tasks_list() {
|
|
let app = build_test_app().await;
|
|
let token = register_and_login(&app, "relaytasks", "relaytasks@example.com").await;
|
|
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri("/api/v1/relay/tasks")
|
|
.header("Authorization", auth_header(&token))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
}
|
|
|
|
// ============ Phase 4: 配置迁移测试 ============
|
|
|
|
#[tokio::test]
|
|
async fn test_config_analysis_empty() {
|
|
let app = build_test_app().await;
|
|
let token = register_and_login(&app, "cfguser", "cfguser@example.com").await;
|
|
|
|
// 初始分析 (无种子数据 → 空列表)
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri("/api/v1/config/analysis")
|
|
.header("Authorization", auth_header(&token))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body_bytes = axum::body::to_bytes(resp.into_body(), MAX_BODY_SIZE).await.unwrap();
|
|
let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
|
|
assert_eq!(body["total_items"], 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_config_seed_and_list() {
|
|
let app = build_test_app().await;
|
|
let token = register_and_login(&app, "cfgseed", "cfgseed@example.com").await;
|
|
|
|
// 种子配置 (普通用户无权限 → 403)
|
|
let seed_req = Request::builder()
|
|
.method("POST")
|
|
.uri("/api/v1/config/seed")
|
|
.header("Authorization", auth_header(&token))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let resp = app.clone().oneshot(seed_req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
|
|
// 列出配置项 (空列表)
|
|
let list_req = Request::builder()
|
|
.method("GET")
|
|
.uri("/api/v1/config/items")
|
|
.header("Authorization", auth_header(&token))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let resp = app.clone().oneshot(list_req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body_bytes = axum::body::to_bytes(resp.into_body(), MAX_BODY_SIZE).await.unwrap();
|
|
let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
|
|
assert!(body.is_array());
|
|
assert_eq!(body.as_array().unwrap().len(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_config_sync() {
|
|
let app = build_test_app().await;
|
|
let token = register_and_login(&app, "cfgsync", "cfgsync@example.com").await;
|
|
|
|
let sync_req = Request::builder()
|
|
.method("POST")
|
|
.uri("/api/v1/config/sync")
|
|
.header("Content-Type", "application/json")
|
|
.header("Authorization", auth_header(&token))
|
|
.body(Body::from(serde_json::to_string(&json!({
|
|
"client_fingerprint": "test-desktop-v1",
|
|
"config_keys": ["server.host", "agent.defaults.default_model"],
|
|
"client_values": {
|
|
"server.host": "0.0.0.0",
|
|
"agent.defaults.default_model": "deepseek/deepseek-chat"
|
|
}
|
|
})).unwrap()))
|
|
.unwrap();
|
|
|
|
let resp = app.clone().oneshot(sync_req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
// 查看同步日志
|
|
let logs_req = Request::builder()
|
|
.method("GET")
|
|
.uri("/api/v1/config/sync-logs")
|
|
.header("Authorization", auth_header(&token))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
|
|
let resp = app.oneshot(logs_req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
}
|