test: add 30 smoke tests for break detection across SaaS/Admin/Desktop
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Layer 1 断裂探测矩阵: - S1-S6: SaaS API 端到端 (auth/lockout/relay/permissions/billing/knowledge) - A1-A6: Admin V2 连通性 (login/dashboard/CRUD/knowledge/roles/models) - D1-D6: Desktop 聊天流 (gateway/kernel/relay/cancel/offline/error) - F1-F6: Desktop 功能闭环 (agent/hands/pipeline/memory/butler/skills) - X1-X6: 跨系统闭环 (provider→desktop/disabled user/knowledge/stats/totp/billing) Also adds: admin-v2 Playwright config, updated spec doc with cross-reference Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
331
crates/zclaw-saas/tests/smoke_saas.rs
Normal file
331
crates/zclaw-saas/tests/smoke_saas.rs
Normal file
@@ -0,0 +1,331 @@
|
||||
//! Smoke Tests — SaaS API 端到端断裂探测
|
||||
//!
|
||||
//! 6 个冒烟测试验证 SaaS 后端的完整业务闭环。
|
||||
//! 每个测试追踪从请求到响应到 DB 状态的完整路径。
|
||||
//!
|
||||
//! 运行: cargo test -p zclaw-saas --test smoke_saas -- --test-threads=1
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
|
||||
// ── S1: 认证闭环 ──────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn s1_auth_full_lifecycle() {
|
||||
let (app, pool) = build_test_app().await;
|
||||
|
||||
// Step 1: Register
|
||||
let (access, refresh, reg_json) = register(&app, "smoke_user", "smoke@test.io", DEFAULT_PASSWORD).await;
|
||||
assert!(!access.is_empty(), "register should return access token");
|
||||
assert!(!refresh.is_empty(), "register should return refresh token");
|
||||
assert_eq!(reg_json["username"].as_str(), Some("smoke_user"));
|
||||
|
||||
// Step 2: GET /me with access token
|
||||
let (status, me) = send(&app, get("/api/v1/auth/me", &access)).await;
|
||||
assert_eq!(status, StatusCode::OK, "GET /me should succeed");
|
||||
assert_eq!(me["username"].as_str(), Some("smoke_user"));
|
||||
assert!(me["pwv"].is_number(), "me should include pwv (password version)");
|
||||
|
||||
// Step 3: Refresh token
|
||||
let (status, refresh_json) = send(
|
||||
&app,
|
||||
post("/api/v1/auth/refresh", "", json!({ "refresh_token": refresh })),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::OK, "refresh should succeed");
|
||||
let new_access = refresh_json["token"].as_str().expect("refresh should return new token");
|
||||
let new_refresh = refresh_json["refresh_token"].as_str().expect("refresh should return new refresh");
|
||||
assert_ne!(new_access, access, "new access token should differ");
|
||||
|
||||
// Step 4: Old refresh token is one-time-use (should fail)
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
post("/api/v1/auth/refresh", "", json!({ "refresh_token": refresh })),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED, "old refresh token should be rejected");
|
||||
|
||||
// Step 5: Logout with new refresh
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
post("/api/v1/auth/logout", new_access, json!({ "refresh_token": new_refresh })),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::OK, "logout should succeed");
|
||||
|
||||
// Step 6: After logout, refresh should fail
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
post("/api/v1/auth/refresh", "", json!({ "refresh_token": new_refresh })),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED, "refresh after logout should fail");
|
||||
|
||||
// DB verification: account exists with correct role
|
||||
let row: (String,) = sqlx::query_as("SELECT role FROM accounts WHERE username = $1")
|
||||
.bind("smoke_user")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("user should exist in DB");
|
||||
assert_eq!(row.0, "user", "new user should have role=user");
|
||||
|
||||
println!("✅ S1 PASS: Auth full lifecycle — register→login→me→refresh→logout");
|
||||
}
|
||||
|
||||
// ── S2: 账户锁定 ──────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn s2_account_lockout() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
|
||||
// Register user
|
||||
register(&app, "lockout_user", "lockout@test.io", DEFAULT_PASSWORD).await;
|
||||
|
||||
// Step 1-4: Wrong password 4 times → should still be allowed
|
||||
for i in 1..=4 {
|
||||
let resp = app.clone().oneshot(post_public(
|
||||
"/api/v1/auth/login",
|
||||
json!({ "username": "lockout_user", "password": "wrong_password" }),
|
||||
)).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "attempt {i}: wrong password should be 401");
|
||||
}
|
||||
|
||||
// Step 5: 5th wrong password → account locked
|
||||
let resp = app.clone().oneshot(post_public(
|
||||
"/api/v1/auth/login",
|
||||
json!({ "username": "lockout_user", "password": "wrong_password" }),
|
||||
)).await.unwrap();
|
||||
let status = resp.status();
|
||||
let body = body_json(resp.into_body()).await;
|
||||
// Account should be locked (either 401 with lock message or 403)
|
||||
assert!(
|
||||
status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN,
|
||||
"5th failed attempt should lock account, got {status}: {body}"
|
||||
);
|
||||
|
||||
// Step 6: Even correct password should fail during lockout
|
||||
let resp = app.clone().oneshot(post_public(
|
||||
"/api/v1/auth/login",
|
||||
json!({ "username": "lockout_user", "password": DEFAULT_PASSWORD }),
|
||||
)).await.unwrap();
|
||||
assert!(
|
||||
resp.status() == StatusCode::UNAUTHORIZED || resp.status() == StatusCode::FORBIDDEN,
|
||||
"correct password during lockout should still fail"
|
||||
);
|
||||
|
||||
println!("✅ S2 PASS: Account lockout — 5 failures trigger lock");
|
||||
}
|
||||
|
||||
// ── S3: Relay 路由闭环 (需 LLM API Key) ──────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn s3_relay_routing() {
|
||||
let llm_key = match std::env::var("LLM_API_KEY") {
|
||||
Ok(k) if !k.is_empty() => k,
|
||||
_ => {
|
||||
eprintln!("⚠️ S3 SKIP: LLM_API_KEY not set, skipping relay routing test");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let (app, pool) = build_test_app().await;
|
||||
let admin = super_admin_token(&app, &pool, "relay_admin").await;
|
||||
|
||||
// Step 1: Create Provider
|
||||
let (status, provider) = send(&app, post("/api/v1/providers", &admin, json!({
|
||||
"name": "smoke_test_provider",
|
||||
"provider_type": "openai",
|
||||
"base_url": "https://api.deepseek.com/v1",
|
||||
"enabled": true
|
||||
}))).await;
|
||||
assert_eq!(status, StatusCode::CREATED, "create provider should succeed: {provider}");
|
||||
let provider_id = provider["id"].as_str().expect("provider should have id");
|
||||
|
||||
// Step 2: Add API Key
|
||||
let (status, _key) = send(&app, post(&format!("/api/v1/providers/{provider_id}/keys"), &admin, json!({
|
||||
"key_value": llm_key
|
||||
}))).await;
|
||||
assert_eq!(status, StatusCode::CREATED, "add API key should succeed");
|
||||
|
||||
// Step 3: Create Model
|
||||
let (status, model) = send(&app, post("/api/v1/models", &admin, json!({
|
||||
"name": "smoke-test-model",
|
||||
"provider_id": provider_id,
|
||||
"model_id": "deepseek-chat",
|
||||
"enabled": true
|
||||
}))).await;
|
||||
assert_eq!(status, StatusCode::CREATED, "create model should succeed: {model}");
|
||||
|
||||
// Step 4: Create regular user for relay
|
||||
let user_token = register_token(&app, "relay_user");
|
||||
|
||||
// Step 5: Relay chat completion (SSE)
|
||||
let resp = app.clone().oneshot(post(
|
||||
"/api/v1/relay/chat/completions",
|
||||
&user_token,
|
||||
json!({
|
||||
"model": "smoke-test-model",
|
||||
"messages": [{ "role": "user", "content": "Say 'hello' in one word" }],
|
||||
"stream": true
|
||||
}),
|
||||
)).await.unwrap();
|
||||
|
||||
let status = resp.status();
|
||||
// Accept 200 (streaming) or create task
|
||||
assert!(
|
||||
status == StatusCode::OK || status == StatusCode::ACCEPTED,
|
||||
"relay chat should return 200/202, got {status}"
|
||||
);
|
||||
|
||||
// Verify relay_task was created in DB
|
||||
let task_count: (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM relay_tasks WHERE account_id = (SELECT id FROM accounts WHERE username = 'relay_user')"
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("should query relay_tasks");
|
||||
assert!(task_count.0 > 0, "relay_task should be created in DB");
|
||||
|
||||
println!("✅ S3 PASS: Relay routing — provider→model→SSE chat→task created");
|
||||
}
|
||||
|
||||
// ── S4: 权限矩阵 ──────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn s4_permission_matrix() {
|
||||
let (app, pool) = build_test_app().await;
|
||||
|
||||
let super_admin = super_admin_token(&app, &pool, "perm_superadmin").await;
|
||||
let user_token = register_token(&app, "perm_user");
|
||||
|
||||
// super_admin should access all protected endpoints
|
||||
let protected_endpoints = vec![
|
||||
("GET", "/api/v1/accounts"),
|
||||
("GET", "/api/v1/providers"),
|
||||
("GET", "/api/v1/models"),
|
||||
("GET", "/api/v1/roles"),
|
||||
("GET", "/api/v1/knowledge/categories"),
|
||||
("GET", "/api/v1/prompts"),
|
||||
];
|
||||
|
||||
for (method, path) in &protected_endpoints {
|
||||
let req = match *method {
|
||||
"GET" => get(path, &super_admin),
|
||||
_ => panic!("unsupported method"),
|
||||
};
|
||||
let (status, body) = send(&app, req).await;
|
||||
assert!(
|
||||
status == StatusCode::OK,
|
||||
"super_admin GET {path} should be 200, got {status}: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
// Regular user should be restricted from admin endpoints
|
||||
let restricted_endpoints = vec![
|
||||
("GET", "/api/v1/accounts"),
|
||||
("GET", "/api/v1/roles"),
|
||||
("POST", "/api/v1/providers"),
|
||||
];
|
||||
|
||||
for (method, path) in &restricted_endpoints {
|
||||
let req = match *method {
|
||||
"GET" => get(path, &user_token),
|
||||
"POST" => post(path, &user_token, json!({})),
|
||||
_ => panic!("unsupported method"),
|
||||
};
|
||||
let (status, _body) = send(&app, req).await;
|
||||
assert!(
|
||||
status == StatusCode::FORBIDDEN,
|
||||
"user {method} {path} should be 403, got {status}"
|
||||
);
|
||||
}
|
||||
|
||||
// User should access relay and self-info
|
||||
let (status, _) = send(&app, get("/api/v1/auth/me", &user_token)).await;
|
||||
assert_eq!(status, StatusCode::OK, "user should access /me");
|
||||
|
||||
let (status, _) = send(&app, get("/api/v1/relay/models", &user_token)).await;
|
||||
assert_eq!(status, StatusCode::OK, "user should access relay/models");
|
||||
|
||||
// Unauthenticated should get 401 on protected
|
||||
let (status, _) = send(&app, get("/api/v1/accounts", "")).await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED, "unauthenticated should get 401");
|
||||
|
||||
println!("✅ S4 PASS: Permission matrix — super_admin/user/unauth roles verified");
|
||||
}
|
||||
|
||||
// ── S5: 计费闭环 ──────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn s5_billing_loop() {
|
||||
let (app, pool) = build_test_app().await;
|
||||
let admin = super_admin_token(&app, &pool, "billing_admin").await;
|
||||
|
||||
// Step 1: Get initial dashboard stats
|
||||
let (status, stats) = send(&app, get("/api/v1/dashboard/stats", &admin)).await;
|
||||
assert_eq!(status, StatusCode::OK, "dashboard stats should succeed");
|
||||
let initial_tasks = stats["tasks_today"].as_i64().unwrap_or(0);
|
||||
|
||||
// Step 2: Get billing usage (should exist even if empty)
|
||||
let user_token = register_token(&app, "billing_user");
|
||||
let (status, _usage) = send(&app, get("/api/v1/billing/usage", &user_token)).await;
|
||||
assert_eq!(status, StatusCode::OK, "billing usage should be accessible");
|
||||
|
||||
// Step 3: Get billing plans
|
||||
let (status, plans) = send(&app, get("/api/v1/billing/plans", &user_token)).await;
|
||||
assert_eq!(status, StatusCode::OK, "billing plans should be accessible");
|
||||
assert!(plans.as_array().is_some() || plans.is_object(), "plans should return data");
|
||||
|
||||
// Verify dashboard stats structure
|
||||
assert!(stats["total_accounts"].is_number(), "stats should have total_accounts");
|
||||
assert!(stats["active_providers"].is_number(), "stats should have active_providers");
|
||||
|
||||
println!("✅ S5 PASS: Billing loop — stats/usage/plans accessible, structure valid");
|
||||
}
|
||||
|
||||
// ── S6: 知识检索闭环 ──────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn s6_knowledge_search() {
|
||||
let (app, pool) = build_test_app().await;
|
||||
let admin = super_admin_token(&app, &pool, "knowledge_admin").await;
|
||||
|
||||
// Step 1: Create category
|
||||
let (status, category) = send(&app, post("/api/v1/knowledge/categories", &admin, json!({
|
||||
"name": "smoke_test_category",
|
||||
"description": "Smoke test category"
|
||||
}))).await;
|
||||
assert_eq!(status, StatusCode::CREATED, "create category should succeed: {category}");
|
||||
let category_id = category["id"].as_str().expect("category should have id");
|
||||
|
||||
// Step 2: Create knowledge item
|
||||
let (status, item) = send(&app, post("/api/v1/knowledge/items", &admin, json!({
|
||||
"title": "API Key 配置指南",
|
||||
"content": "在 Model Services 页面添加 Provider 后,点击 API Key 池添加密钥",
|
||||
"category_id": category_id,
|
||||
"tags": ["api", "key", "配置"]
|
||||
}))).await;
|
||||
assert_eq!(status, StatusCode::CREATED, "create knowledge item should succeed: {item}");
|
||||
let item_id = item["id"].as_str().expect("item should have id");
|
||||
|
||||
// Step 3: Search for the item
|
||||
let (status, results) = send(&app, post("/api/v1/knowledge/search", &admin, json!({
|
||||
"query": "API Key 配置",
|
||||
"limit": 10
|
||||
}))).await;
|
||||
assert_eq!(status, StatusCode::OK, "search should succeed");
|
||||
let items = results["items"].as_array().or_else(|| results.as_array());
|
||||
assert!(items.is_some(), "search should return results array");
|
||||
let found = items.unwrap().iter().any(|i| {
|
||||
i["id"].as_str() == Some(item_id) || i["title"].as_str() == Some("API Key 配置指南")
|
||||
});
|
||||
assert!(found, "search should find the created item");
|
||||
|
||||
// DB verification: item exists with category
|
||||
let row: (String,) = sqlx::query_as("SELECT title FROM knowledge_items WHERE id = $1")
|
||||
.bind(item_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("knowledge item should exist in DB");
|
||||
assert_eq!(row.0, "API Key 配置指南");
|
||||
|
||||
println!("✅ S6 PASS: Knowledge search — category→item→search→found");
|
||||
}
|
||||
Reference in New Issue
Block a user