chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成
包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、 文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
This commit is contained in:
203
crates/zclaw-saas/tests/account_test.rs
Normal file
203
crates/zclaw-saas/tests/account_test.rs
Normal file
@@ -0,0 +1,203 @@
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::*;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Account listing
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_accounts_forbidden_for_regular_user() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "useracct").await;
|
||||
let (status, _) = send(&app, get("/api/v1/accounts", &token)).await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_accounts_success_as_admin() {
|
||||
let (app, pool) = build_test_app().await;
|
||||
let admin = admin_token(&app, &pool, "adminacct").await;
|
||||
let (status, body) = send(&app, get("/api/v1/accounts", &admin)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
// Should include at least the admin + the auto-seeded testadmin
|
||||
assert!(body["items"].is_array() || body.is_array());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_own_account() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "ownacct").await;
|
||||
|
||||
// First get own account info from /me
|
||||
let (status, me) = send(&app, get("/api/v1/auth/me", &token)).await;
|
||||
assert_eq!(status, StatusCode::OK, "get /me: {me}");
|
||||
let account_id = me["id"].as_str().unwrap();
|
||||
eprintln!("DEBUG account_id = {account_id}");
|
||||
|
||||
let url = format!("/api/v1/accounts/{}", account_id);
|
||||
eprintln!("DEBUG url = {url}");
|
||||
let (status, body) = send(&app, get(&url, &token)).await;
|
||||
eprintln!("DEBUG status = {status}, body = {body}");
|
||||
assert_eq!(status, StatusCode::OK, "get own account: {body}");
|
||||
assert_eq!(body["username"], "ownacct");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_own_account_display_name() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "updateacct").await;
|
||||
|
||||
// Get account ID from /me
|
||||
let (_, me) = send(&app, get("/api/v1/auth/me", &token)).await;
|
||||
let account_id = me["id"].as_str().unwrap();
|
||||
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
patch(
|
||||
&format!("/api/v1/accounts/{account_id}"),
|
||||
&token,
|
||||
serde_json::json!({ "display_name": "New Display Name" }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::OK, "update account: {body}");
|
||||
assert_eq!(body["display_name"], "New Display Name");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// API Token lifecycle
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_token_create_list_revoke() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "tokenuser").await;
|
||||
|
||||
// Create
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
post(
|
||||
"/api/v1/tokens",
|
||||
&token,
|
||||
serde_json::json!({ "name": "test-token", "permissions": ["model:read", "relay:use"] }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::OK, "create token: {body}");
|
||||
let raw_token = body["token"].as_str().unwrap();
|
||||
assert!(raw_token.starts_with("zclaw_"));
|
||||
let token_id = body["id"].as_str().unwrap();
|
||||
|
||||
// List (paginated response: {items, total, page, page_size})
|
||||
let (status, list) = send(&app, get("/api/v1/tokens", &token)).await;
|
||||
assert_eq!(status, StatusCode::OK, "list tokens: {list}");
|
||||
assert!(list["items"].is_array(), "tokens list should have items field: {list}");
|
||||
assert_eq!(list["items"].as_array().unwrap().len(), 1);
|
||||
|
||||
// Use the API token to authenticate
|
||||
let (status, _) = send(&app, get("/api/v1/auth/me", raw_token)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
// Revoke
|
||||
let (status, _) = send(&app, delete(&format!("/api/v1/tokens/{token_id}"), &token)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
// After revoke, API token no longer works
|
||||
let (status, _) = send(&app, get("/api/v1/auth/me", raw_token)).await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Device registration
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn device_register_and_list() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "deviceuser").await;
|
||||
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
post(
|
||||
"/api/v1/devices/register",
|
||||
&token,
|
||||
serde_json::json!({
|
||||
"device_id": "test-device-001",
|
||||
"device_name": "Test Desktop",
|
||||
"platform": "windows",
|
||||
"app_version": "0.1.0"
|
||||
}),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
let (status, body) = send(&app, get("/api/v1/devices", &token)).await;
|
||||
assert_eq!(status, StatusCode::OK, "list devices: {body}");
|
||||
let devices = body["items"].as_array().expect("devices should be paginated {items}");
|
||||
assert_eq!(devices.len(), 1);
|
||||
assert_eq!(devices[0]["device_id"], "test-device-001");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn device_upsert_on_reregister() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "upsertdev").await;
|
||||
|
||||
send(&app, post("/api/v1/devices/register", &token, serde_json::json!({
|
||||
"device_id": "dev-upsert", "device_name": "Old Name"
|
||||
}))).await;
|
||||
|
||||
send(&app, post("/api/v1/devices/register", &token, serde_json::json!({
|
||||
"device_id": "dev-upsert", "device_name": "New Name"
|
||||
}))).await;
|
||||
|
||||
let (_, body) = send(&app, get("/api/v1/devices", &token)).await;
|
||||
let devs = body["items"].as_array().expect("devices should be paginated {items}");
|
||||
assert_eq!(devs.len(), 1);
|
||||
assert_eq!(devs[0]["device_name"], "New Name");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn device_heartbeat() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "hbuser").await;
|
||||
|
||||
// Register first
|
||||
send(&app, post("/api/v1/devices/register", &token, serde_json::json!({
|
||||
"device_id": "hb-dev"
|
||||
}))).await;
|
||||
|
||||
// Heartbeat
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
post("/api/v1/devices/heartbeat", &token, serde_json::json!({ "device_id": "hb-dev" })),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
// Heartbeat nonexistent → 404
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
post("/api/v1/devices/heartbeat", &token, serde_json::json!({ "device_id": "ghost" })),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Operation logs (admin only)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn operation_logs_forbidden_for_user() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "loguser").await;
|
||||
let (status, _) = send(&app, get("/api/v1/logs/operations", &token)).await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_stats_admin() {
|
||||
let (app, pool) = build_test_app().await;
|
||||
let admin = admin_token(&app, &pool, "statsadmin").await;
|
||||
let (status, _) = send(&app, get("/api/v1/stats/dashboard", &admin)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
97
crates/zclaw-saas/tests/agent_template_test.rs
Normal file
97
crates/zclaw-saas/tests/agent_template_test.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::*;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// List templates
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_template_list_empty() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "atlist").await;
|
||||
let (status, body) = send(&app, get("/api/v1/agent-templates", &token)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(body.is_array() || body["items"].is_array());
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Full CRUD
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_template_crud() {
|
||||
let (app, pool) = build_test_app().await;
|
||||
let admin = admin_token(&app, &pool, "atadmin").await;
|
||||
|
||||
// Create
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
post(
|
||||
"/api/v1/agent-templates",
|
||||
&admin,
|
||||
serde_json::json!({
|
||||
"name": "Test Agent",
|
||||
"description": "A test agent template",
|
||||
"category": "general",
|
||||
"model": "test-model-v1",
|
||||
"system_prompt": "You are a test agent.",
|
||||
"tools": ["search", "browser"],
|
||||
"capabilities": ["reasoning", "code"],
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 4096
|
||||
}),
|
||||
),
|
||||
).await;
|
||||
if status != StatusCode::OK {
|
||||
eprintln!("ERROR create agent template: status={status}, body={body}");
|
||||
}
|
||||
assert_eq!(status, StatusCode::OK, "create agent template: {body}");
|
||||
let tmpl_id = body["id"].as_str().unwrap();
|
||||
assert_eq!(body["name"], "Test Agent");
|
||||
|
||||
// Get
|
||||
let (status, body) = send(&app, get(&format!("/api/v1/agent-templates/{tmpl_id}"), &admin)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["name"], "Test Agent");
|
||||
assert_eq!(body["model"], "test-model-v1");
|
||||
|
||||
// Update (POST for update)
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
post(
|
||||
&format!("/api/v1/agent-templates/{tmpl_id}"),
|
||||
&admin,
|
||||
serde_json::json!({
|
||||
"description": "Updated description",
|
||||
"temperature": 0.5
|
||||
}),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["description"], "Updated description");
|
||||
|
||||
// Archive (DELETE)
|
||||
let (status, _) = send(&app, delete(&format!("/api/v1/agent-templates/{tmpl_id}"), &admin)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Permission enforcement
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_template_create_forbidden_for_user() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "atuser").await;
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
post(
|
||||
"/api/v1/agent-templates",
|
||||
&token,
|
||||
serde_json::json!({ "name": "Forbidden" }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
}
|
||||
385
crates/zclaw-saas/tests/auth_test.rs
Normal file
385
crates/zclaw-saas/tests/auth_test.rs
Normal file
@@ -0,0 +1,385 @@
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::*;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Registration
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_success() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let (token, refresh, json) = register(&app, "alice", "alice@test.io", DEFAULT_PASSWORD).await;
|
||||
assert!(!token.is_empty());
|
||||
assert!(!refresh.is_empty());
|
||||
assert_eq!(json["account"]["username"], "alice");
|
||||
assert_eq!(json["account"]["role"], "user");
|
||||
assert_eq!(json["account"]["status"], "active");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_duplicate_username() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
register(&app, "dupuser", "dup@test.io", DEFAULT_PASSWORD).await;
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
post_public(
|
||||
"/api/v1/auth/register",
|
||||
serde_json::json!({ "username": "dupuser", "email": "other@test.io", "password": DEFAULT_PASSWORD }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_duplicate_email() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
register(&app, "user1", "same@test.io", DEFAULT_PASSWORD).await;
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
post_public(
|
||||
"/api/v1/auth/register",
|
||||
serde_json::json!({ "username": "user2", "email": "same@test.io", "password": DEFAULT_PASSWORD }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_validation_short_username() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
post_public(
|
||||
"/api/v1/auth/register",
|
||||
serde_json::json!({ "username": "ab", "email": "a@b.c", "password": DEFAULT_PASSWORD }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["error"], "INVALID_INPUT");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_validation_bad_email() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
post_public(
|
||||
"/api/v1/auth/register",
|
||||
serde_json::json!({ "username": "goodname", "email": "no-at-sign", "password": DEFAULT_PASSWORD }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_validation_short_password() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
post_public(
|
||||
"/api/v1/auth/register",
|
||||
serde_json::json!({ "username": "goodname", "email": "a@b.c", "password": "short" }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Login
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_success() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
register(&app, "loginuser", "login@test.io", DEFAULT_PASSWORD).await;
|
||||
let (token, refresh, json) = login(&app, "loginuser", DEFAULT_PASSWORD).await;
|
||||
assert!(!token.is_empty());
|
||||
assert!(!refresh.is_empty());
|
||||
assert_eq!(json["account"]["username"], "loginuser");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_wrong_password() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
register(&app, "wrongpwd", "wrong@test.io", DEFAULT_PASSWORD).await;
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
post_public(
|
||||
"/api/v1/auth/login",
|
||||
serde_json::json!({ "username": "wrongpwd", "password": "incorrect_password" }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(body["error"], "AUTH_ERROR");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_nonexistent_user() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
post_public(
|
||||
"/api/v1/auth/login",
|
||||
serde_json::json!({ "username": "ghost", "password": "whatever" }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Auth chain: register → login → me (P0)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_chain_register_login_me() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
|
||||
// 1. Register
|
||||
let (token, _refresh, json) = register(&app, "chainuser", "chain@test.io", DEFAULT_PASSWORD).await;
|
||||
assert_eq!(json["account"]["username"], "chainuser");
|
||||
|
||||
// 2. GET /me with the registration token
|
||||
let (status, me) = send(&app, get("/api/v1/auth/me", &token)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(me["username"], "chainuser");
|
||||
assert_eq!(me["role"], "user");
|
||||
|
||||
// 3. Login separately
|
||||
let (token2, _, _) = login(&app, "chainuser", DEFAULT_PASSWORD).await;
|
||||
|
||||
// 4. GET /me with the login token
|
||||
let (status, me2) = send(&app, get("/api/v1/auth/me", &token2)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(me2["id"], me["id"]); // same account
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn me_without_token_is_unauthorized() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let req = axum::http::Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/v1/auth/me")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
let (status, _) = send(&app, req).await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn me_with_invalid_token_is_unauthorized() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let (status, _) = send(&app, get("/api/v1/auth/me", "invalid.jwt.token")).await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Refresh token
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_token_success() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let (_, refresh, _) = register(&app, "refreshuser", "refresh@test.io", DEFAULT_PASSWORD).await;
|
||||
|
||||
// Use refresh token to get a new pair
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
post_public(
|
||||
"/api/v1/auth/refresh",
|
||||
serde_json::json!({ "refresh_token": refresh }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(body["token"].is_string());
|
||||
assert!(body["refresh_token"].is_string());
|
||||
let new_token = body["token"].as_str().unwrap();
|
||||
let new_refresh = body["refresh_token"].as_str().unwrap();
|
||||
assert!(!new_token.is_empty());
|
||||
assert!(!new_refresh.is_empty());
|
||||
|
||||
// New token works for /me
|
||||
let (status, _) = send(&app, get("/api/v1/auth/me", new_token)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_token_one_time_use() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let (_, refresh, _) = register(&app, "onetime", "onetime@test.io", DEFAULT_PASSWORD).await;
|
||||
|
||||
// First refresh succeeds
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
post_public(
|
||||
"/api/v1/auth/refresh",
|
||||
serde_json::json!({ "refresh_token": refresh }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
// Second use of the same refresh token fails
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
post_public(
|
||||
"/api/v1/auth/refresh",
|
||||
serde_json::json!({ "refresh_token": refresh }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_with_invalid_token() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
post_public(
|
||||
"/api/v1/auth/refresh",
|
||||
serde_json::json!({ "refresh_token": "garbage" }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Password change
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn change_password_success() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "pwduser").await;
|
||||
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
put(
|
||||
"/api/v1/auth/password",
|
||||
&token,
|
||||
serde_json::json!({ "old_password": DEFAULT_PASSWORD, "new_password": "BrandNewP@ss1" }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
// Login with new password works
|
||||
let (_, _, _) = login(&app, "pwduser", "BrandNewP@ss1").await;
|
||||
|
||||
// Login with old password fails
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
post_public(
|
||||
"/api/v1/auth/login",
|
||||
serde_json::json!({ "username": "pwduser", "password": DEFAULT_PASSWORD }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn change_password_wrong_old() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "wrongold").await;
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
put(
|
||||
"/api/v1/auth/password",
|
||||
&token,
|
||||
serde_json::json!({ "old_password": "wrong_old_pass", "new_password": "BrandNewP@ss1" }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn change_password_too_short() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "shortpwd").await;
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
put(
|
||||
"/api/v1/auth/password",
|
||||
&token,
|
||||
serde_json::json!({ "old_password": DEFAULT_PASSWORD, "new_password": "abc" }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// TOTP 2FA (P0 chain test)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn totp_setup_and_disable() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "totpuser").await;
|
||||
|
||||
// Setup TOTP → returns otpauth_uri + secret
|
||||
let (status, body) = send(&app, post("/api/v1/auth/totp/setup", &token, serde_json::json!({}))).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(body["otpauth_uri"].is_string());
|
||||
assert!(body["secret"].is_string());
|
||||
|
||||
// Disable TOTP (requires password)
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
post("/api/v1/auth/totp/disable", &token, serde_json::json!({ "password": DEFAULT_PASSWORD })),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
// After disable, login without TOTP code succeeds
|
||||
let (_, _, login_json) = login(&app, "totpuser", DEFAULT_PASSWORD).await;
|
||||
assert_eq!(login_json["account"]["totp_enabled"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn totp_disable_wrong_password() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "totpwrong").await;
|
||||
// Setup first
|
||||
send(&app, post("/api/v1/auth/totp/setup", &token, serde_json::json!({}))).await;
|
||||
// Try disable with wrong password
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
post(
|
||||
"/api/v1/auth/totp/disable",
|
||||
&token,
|
||||
serde_json::json!({ "password": "wrong_password_here" }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn totp_verify_wrong_code() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "totpbadcode").await;
|
||||
// Setup
|
||||
send(&app, post("/api/v1/auth/totp/setup", &token, serde_json::json!({}))).await;
|
||||
// Verify with a definitely-wrong code
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
post(
|
||||
"/api/v1/auth/totp/verify",
|
||||
&token,
|
||||
serde_json::json!({ "code": "000000" }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Health endpoint
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_check() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let req = axum::http::Request::builder()
|
||||
.uri("/api/health")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
let (status, _) = send(&app, req).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
382
crates/zclaw-saas/tests/common/mod.rs
Normal file
382
crates/zclaw-saas/tests/common/mod.rs
Normal file
@@ -0,0 +1,382 @@
|
||||
//! Integration test harness for zclaw-saas
|
||||
//!
|
||||
//! Uses a **shared** PostgreSQL database (`zclaw_test_shared`) with per-test
|
||||
//! TRUNCATE isolation. Only one database is created; each test truncates all
|
||||
//! tables and re-seeds via `init_db`.
|
||||
//!
|
||||
//! # Setup
|
||||
//!
|
||||
//! ```bash
|
||||
//! # Start PostgreSQL (e.g. via Docker Compose)
|
||||
//! docker compose up -d postgres
|
||||
//!
|
||||
//! # Set the test database URL (point to the base DB for CREATE DATABASE)
|
||||
//! export TEST_DATABASE_URL="postgres://postgres:123123@localhost:5432/zclaw"
|
||||
//!
|
||||
//! # Run tests
|
||||
//! cargo test -p zclaw-saas
|
||||
//! ```
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::Router;
|
||||
use sqlx::PgPool;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tower::ServiceExt;
|
||||
use zclaw_saas::config::SaaSConfig;
|
||||
use zclaw_saas::db::init_db;
|
||||
use zclaw_saas::state::AppState;
|
||||
|
||||
pub const MAX_BODY: usize = 2 * 1024 * 1024;
|
||||
pub const DEFAULT_PASSWORD: &str = "testpassword123";
|
||||
|
||||
const SHARED_DB_NAME: &str = "zclaw_test_shared";
|
||||
|
||||
/// Schema version counter — increment to force DROP+CREATE on next run.
|
||||
const SCHEMA_VERSION: u32 = 2;
|
||||
|
||||
/// Whether the shared test database has been created at the current schema version.
|
||||
static DB_CREATED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
// ── Database helpers ─────────────────────────────────────────────
|
||||
|
||||
/// Resolve the base test database URL (used to connect for CREATE DATABASE).
|
||||
pub fn test_database_url() -> String {
|
||||
std::env::var("TEST_DATABASE_URL")
|
||||
.or_else(|_| std::env::var("DATABASE_URL"))
|
||||
.unwrap_or_else(|_| "postgres://postgres:123123@localhost:5432/zclaw".into())
|
||||
}
|
||||
|
||||
/// Build the shared test database URL by replacing the database name.
|
||||
fn shared_db_url() -> String {
|
||||
let mut url = test_database_url();
|
||||
if let Some(pos) = url.rfind('/') {
|
||||
url.truncate(pos + 1);
|
||||
url.push_str(SHARED_DB_NAME);
|
||||
}
|
||||
url
|
||||
}
|
||||
|
||||
/// Ensure the shared test database exists with a clean schema.
|
||||
/// Runs once per process: drops the old DB and recreates it.
|
||||
async fn ensure_shared_db() -> String {
|
||||
if !DB_CREATED.swap(true, Ordering::SeqCst) {
|
||||
let base = test_database_url();
|
||||
let pool = PgPool::connect(&base)
|
||||
.await
|
||||
.expect("Cannot connect to PostgreSQL — is it running?");
|
||||
// Drop + recreate for a clean schema
|
||||
let _ = sqlx::query(&format!("DROP DATABASE IF EXISTS \"{}\"", SHARED_DB_NAME))
|
||||
.execute(&pool)
|
||||
.await;
|
||||
sqlx::query(&format!("CREATE DATABASE \"{}\"", SHARED_DB_NAME))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("Failed to create shared test database");
|
||||
drop(pool);
|
||||
}
|
||||
shared_db_url()
|
||||
}
|
||||
|
||||
/// Truncate all public tables in the database (CASCADE handles FK).
|
||||
async fn truncate_all_tables(pool: &PgPool) {
|
||||
sqlx::query(
|
||||
r#"DO $$
|
||||
DECLARE
|
||||
r RECORD;
|
||||
BEGIN
|
||||
FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP
|
||||
EXECUTE 'TRUNCATE TABLE ' || quote_ident(r.tablename) || ' CASCADE';
|
||||
END LOOP;
|
||||
END$$;"#,
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("Failed to truncate tables");
|
||||
}
|
||||
|
||||
// ── App builder ──────────────────────────────────────────────────
|
||||
|
||||
/// Build a full Axum `Router` wired to the shared test database.
|
||||
///
|
||||
/// Flow per test:
|
||||
/// 1. Ensure shared DB exists (once)
|
||||
/// 2. Truncate all tables (isolation)
|
||||
/// 3. Re-run `init_db` to seed fresh data
|
||||
/// 4. Return `(Router, PgPool)`
|
||||
pub async fn build_test_app() -> (Router, PgPool) {
|
||||
let db_url = ensure_shared_db().await;
|
||||
|
||||
// Dev-mode env vars
|
||||
std::env::set_var("ZCLAW_SAAS_DEV", "true");
|
||||
std::env::set_var("ZCLAW_SAAS_JWT_SECRET", "test-jwt-secret-do-not-use-in-prod");
|
||||
std::env::set_var("ZCLAW_ADMIN_USERNAME", "testadmin");
|
||||
std::env::set_var("ZCLAW_ADMIN_PASSWORD", "Admin123456");
|
||||
|
||||
// Truncate all data for test isolation
|
||||
let truncate_pool = PgPool::connect(&db_url)
|
||||
.await
|
||||
.expect("Cannot connect to shared test DB");
|
||||
truncate_all_tables(&truncate_pool).await;
|
||||
drop(truncate_pool);
|
||||
|
||||
// init_db: schema (IF NOT EXISTS, fast) + seed data
|
||||
let pool = init_db(&db_url).await.expect("init_db failed");
|
||||
|
||||
let mut config = SaaSConfig::default();
|
||||
config.auth.jwt_expiration_hours = 24;
|
||||
config.auth.refresh_token_hours = 168;
|
||||
config.rate_limit.requests_per_minute = 10_000;
|
||||
config.rate_limit.burst = 1_000;
|
||||
|
||||
let state = AppState::new(pool.clone(), config).expect("AppState::new failed");
|
||||
let router = build_router(state);
|
||||
(router, pool)
|
||||
}
|
||||
|
||||
fn build_router(state: AppState) -> Router {
|
||||
use axum::middleware;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
let public_routes = zclaw_saas::auth::routes()
|
||||
.route("/api/health", axum::routing::get(health_handler));
|
||||
|
||||
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())
|
||||
.merge(zclaw_saas::role::routes())
|
||||
.merge(zclaw_saas::prompt::routes())
|
||||
.merge(zclaw_saas::agent_template::routes())
|
||||
.merge(zclaw_saas::telemetry::routes())
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
zclaw_saas::middleware::api_version_middleware,
|
||||
))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
zclaw_saas::middleware::request_id_middleware,
|
||||
))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
zclaw_saas::middleware::rate_limit_middleware,
|
||||
))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
zclaw_saas::auth::auth_middleware,
|
||||
));
|
||||
|
||||
Router::new()
|
||||
.merge(public_routes)
|
||||
.merge(protected_routes)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(
|
||||
CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any),
|
||||
)
|
||||
.with_state(state)
|
||||
.layer(axum::middleware::from_fn(inject_connect_info))
|
||||
}
|
||||
|
||||
/// Simple health handler for testing (mirrors main.rs health_handler).
|
||||
async fn health_handler(State(state): axum::extract::State<AppState>) -> axum::Json<serde_json::Value> {
|
||||
let db_healthy = sqlx::query_scalar::<_, i32>("SELECT 1")
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.ok()
|
||||
.map(|v| v == 1)
|
||||
.unwrap_or(false);
|
||||
let status = if db_healthy { "healthy" } else { "degraded" };
|
||||
axum::Json(serde_json::json!({ "status": status, "database": db_healthy }))
|
||||
}
|
||||
|
||||
use axum::extract::State;
|
||||
async fn inject_connect_info(
|
||||
mut req: axum::extract::Request,
|
||||
next: axum::middleware::Next,
|
||||
) -> axum::response::Response {
|
||||
use axum::extract::ConnectInfo;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
req.extensions_mut().insert(ConnectInfo::<SocketAddr>(
|
||||
"127.0.0.1:12345".parse().unwrap(),
|
||||
));
|
||||
next.run(req).await
|
||||
}
|
||||
|
||||
// ── HTTP helpers ─────────────────────────────────────────────────
|
||||
|
||||
pub async fn body_bytes(body: Body) -> Vec<u8> {
|
||||
axum::body::to_bytes(body, MAX_BODY)
|
||||
.await
|
||||
.expect("body too large")
|
||||
.to_vec()
|
||||
}
|
||||
|
||||
pub async fn body_json(body: Body) -> serde_json::Value {
|
||||
let bytes = body_bytes(body).await;
|
||||
serde_json::from_slice(&bytes).unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"Failed to parse JSON: {}\nBody: {}",
|
||||
e,
|
||||
String::from_utf8_lossy(&bytes)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get(uri: &str, token: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(uri)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn delete(uri: &str, token: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("DELETE")
|
||||
.uri(uri)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn post(uri: &str, token: &str, body: serde_json::Value) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(uri)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn post_public(uri: &str, body: serde_json::Value) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(uri)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn put(uri: &str, token: &str, body: serde_json::Value) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri(uri)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn patch(uri: &str, token: &str, body: serde_json::Value) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("PATCH")
|
||||
.uri(uri)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Send request and return (status, body_json).
|
||||
/// If body is empty, returns `serde_json::Value::Null` instead of panicking.
|
||||
pub async fn send(app: &Router, req: Request<Body>) -> (StatusCode, serde_json::Value) {
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let status = resp.status();
|
||||
let bytes = body_bytes(resp.into_body()).await;
|
||||
if bytes.is_empty() {
|
||||
return (status, serde_json::Value::Null);
|
||||
}
|
||||
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"Failed to parse JSON: {}\nBody: {}",
|
||||
e,
|
||||
String::from_utf8_lossy(&bytes)
|
||||
)
|
||||
});
|
||||
(status, json)
|
||||
}
|
||||
|
||||
// ── Auth helpers ─────────────────────────────────────────────────
|
||||
|
||||
/// Register a new user. Returns (access_token, refresh_token, response_json).
|
||||
pub async fn register(
|
||||
app: &Router,
|
||||
username: &str,
|
||||
email: &str,
|
||||
password: &str,
|
||||
) -> (String, String, serde_json::Value) {
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(post_public(
|
||||
"/api/v1/auth/register",
|
||||
serde_json::json!({ "username": username, "email": email, "password": password }),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let status = resp.status();
|
||||
let json = body_json(resp.into_body()).await;
|
||||
assert_eq!(status, StatusCode::CREATED, "register failed: {json}");
|
||||
let token = json["token"].as_str().unwrap().to_string();
|
||||
let refresh = json["refresh_token"].as_str().unwrap().to_string();
|
||||
(token, refresh, json)
|
||||
}
|
||||
|
||||
/// Login. Returns (access_token, refresh_token, response_json).
|
||||
pub async fn login(
|
||||
app: &Router,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> (String, String, serde_json::Value) {
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(post_public(
|
||||
"/api/v1/auth/login",
|
||||
serde_json::json!({ "username": username, "password": password }),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let status = resp.status();
|
||||
let json = body_json(resp.into_body()).await;
|
||||
assert_eq!(status, StatusCode::OK, "login failed: {json}");
|
||||
let token = json["token"].as_str().unwrap().to_string();
|
||||
let refresh = json["refresh_token"].as_str().unwrap().to_string();
|
||||
(token, refresh, json)
|
||||
}
|
||||
|
||||
/// Register + return access token.
|
||||
pub async fn register_token(app: &Router, username: &str) -> String {
|
||||
let email = format!("{username}@test.io");
|
||||
register(app, username, &email, DEFAULT_PASSWORD).await.0
|
||||
}
|
||||
|
||||
/// Create a user and promote to `admin`. Returns fresh JWT with admin permissions.
|
||||
pub async fn admin_token(app: &Router, pool: &PgPool, username: &str) -> String {
|
||||
let email = format!("{username}@test.io");
|
||||
register(app, username, &email, DEFAULT_PASSWORD).await;
|
||||
sqlx::query("UPDATE accounts SET role = 'admin' WHERE username = $1")
|
||||
.bind(username)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
login(app, username, DEFAULT_PASSWORD).await.0
|
||||
}
|
||||
|
||||
/// Create a user and promote to `super_admin`. Returns fresh JWT.
|
||||
pub async fn super_admin_token(app: &Router, pool: &PgPool, username: &str) -> String {
|
||||
let email = format!("{username}@test.io");
|
||||
register(app, username, &email, DEFAULT_PASSWORD).await;
|
||||
sqlx::query("UPDATE accounts SET role = 'super_admin' WHERE username = $1")
|
||||
.bind(username)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
login(app, username, DEFAULT_PASSWORD).await.0
|
||||
}
|
||||
174
crates/zclaw-saas/tests/migration_test.rs
Normal file
174
crates/zclaw-saas/tests/migration_test.rs
Normal file
@@ -0,0 +1,174 @@
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::*;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Config analysis
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_analysis_empty() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "cfganalyze").await;
|
||||
let (status, body) = send(&app, get("/api/v1/config/analysis", &token)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["total_items"], 0);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Config items CRUD
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_items_crud() {
|
||||
let (app, pool) = build_test_app().await;
|
||||
let admin = admin_token(&app, &pool, "cfgadmin").await;
|
||||
|
||||
// Create config item
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
post(
|
||||
"/api/v1/config/items",
|
||||
&admin,
|
||||
serde_json::json!({
|
||||
"category": "server",
|
||||
"key_path": "server.host",
|
||||
"value_type": "string",
|
||||
"current_value": "0.0.0.0",
|
||||
"description": "Server bind address"
|
||||
}),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::CREATED, "create config item: {body}");
|
||||
let item_id = body["id"].as_str().unwrap();
|
||||
|
||||
// List
|
||||
let (status, list) = send(&app, get("/api/v1/config/items", &admin)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(list.is_array() || list["items"].is_array());
|
||||
|
||||
// Get
|
||||
let (status, body) = send(&app, get(&format!("/api/v1/config/items/{item_id}"), &admin)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["key_path"], "server.host");
|
||||
|
||||
// Update
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
put(
|
||||
&format!("/api/v1/config/items/{item_id}"),
|
||||
&admin,
|
||||
serde_json::json!({ "current_value": "127.0.0.1" }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["current_value"], "127.0.0.1");
|
||||
|
||||
// Delete
|
||||
let (status, _) = send(&app, delete(&format!("/api/v1/config/items/{item_id}"), &admin)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_items_write_forbidden_for_user() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "cfguser").await;
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
post(
|
||||
"/api/v1/config/items",
|
||||
&token,
|
||||
serde_json::json!({ "category": "x", "key_path": "y", "value_type": "string" }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Config seed
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_seed_admin_only() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let user_token = register_token(&app, "cfgseeduser").await;
|
||||
let (status, _) = send(&app, post("/api/v1/config/seed", &user_token, serde_json::json!({}))).await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Config sync (push)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_sync_push() {
|
||||
let (app, pool) = build_test_app().await;
|
||||
let admin = admin_token(&app, &pool, "cfgsync").await;
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
post(
|
||||
"/api/v1/config/sync",
|
||||
&admin,
|
||||
serde_json::json!({
|
||||
"client_fingerprint": "test-desktop-v1",
|
||||
"action": "push",
|
||||
"config_keys": ["server.host", "server.port"],
|
||||
"client_values": { "server.host": "192.168.1.1", "server.port": "9090" }
|
||||
}),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::OK, "config sync push: {body}");
|
||||
// Push mode: keys don't exist in SaaS → auto-created
|
||||
assert_eq!(body["created"], 2);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Config diff
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_diff() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "cfgdiff").await;
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
post(
|
||||
"/api/v1/config/diff",
|
||||
&token,
|
||||
serde_json::json!({
|
||||
"client_fingerprint": "test-client",
|
||||
"action": "push",
|
||||
"config_keys": ["server.host"],
|
||||
"client_values": { "server.host": "0.0.0.0" }
|
||||
}),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["total_keys"], 1);
|
||||
assert!(body["items"].is_array());
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Config sync logs
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_sync_logs() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "cfglogs").await;
|
||||
let (status, _) = send(&app, get("/api/v1/config/sync-logs", &token)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Config pull
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_pull_empty() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "cfgpull").await;
|
||||
let (status, _) = send(&app, get("/api/v1/config/pull", &token)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
234
crates/zclaw-saas/tests/model_config_test.rs
Normal file
234
crates/zclaw-saas/tests/model_config_test.rs
Normal file
@@ -0,0 +1,234 @@
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::*;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Provider CRUD
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_crud_full_lifecycle() {
|
||||
let (app, pool) = build_test_app().await;
|
||||
let admin = admin_token(&app, &pool, "provadmin").await;
|
||||
|
||||
// Create
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
post(
|
||||
"/api/v1/providers",
|
||||
&admin,
|
||||
serde_json::json!({
|
||||
"name": "test-provider",
|
||||
"display_name": "Test Provider",
|
||||
"base_url": "https://api.example.com/v1"
|
||||
}),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::CREATED, "create provider failed: {body}");
|
||||
let provider_id = body["id"].as_str().unwrap().to_string();
|
||||
|
||||
// List (paginated)
|
||||
let (status, body) = send(&app, get("/api/v1/providers", &admin)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let items = body["items"].as_array().expect("providers should be paginated {items}");
|
||||
assert!(items.iter().any(|p| p["id"] == provider_id));
|
||||
|
||||
// Get
|
||||
let (status, body) = send(&app, get(&format!("/api/v1/providers/{provider_id}"), &admin)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["name"], "test-provider");
|
||||
|
||||
// Update
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
patch(
|
||||
&format!("/api/v1/providers/{provider_id}"),
|
||||
&admin,
|
||||
serde_json::json!({ "display_name": "Updated Provider" }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["display_name"], "Updated Provider");
|
||||
|
||||
// Delete
|
||||
let (status, _) = send(&app, delete(&format!("/api/v1/providers/{provider_id}"), &admin)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
// Verify deleted
|
||||
let (status, _) = send(&app, get(&format!("/api/v1/providers/{provider_id}"), &admin)).await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_create_forbidden_for_user() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "provuser").await;
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
post(
|
||||
"/api/v1/providers",
|
||||
&token,
|
||||
serde_json::json!({ "name": "x", "display_name": "X", "base_url": "https://x.com" }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_list_accessible_to_all_authenticated() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "listprovuser").await;
|
||||
let (status, body) = send(&app, get("/api/v1/providers", &token)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(body["items"].is_array(), "providers list should be paginated: {body}");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Model CRUD
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_crud_with_provider() {
|
||||
let (app, pool) = build_test_app().await;
|
||||
let admin = admin_token(&app, &pool, "modeladmin").await;
|
||||
|
||||
// Create provider first
|
||||
let (_, prov_body) = send(
|
||||
&app,
|
||||
post(
|
||||
"/api/v1/providers",
|
||||
&admin,
|
||||
serde_json::json!({ "name": "model-prov", "display_name": "Model Prov", "base_url": "https://api.test.com/v1" }),
|
||||
),
|
||||
).await;
|
||||
let provider_id = prov_body["id"].as_str().unwrap();
|
||||
|
||||
// Create model
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
post(
|
||||
"/api/v1/models",
|
||||
&admin,
|
||||
serde_json::json!({
|
||||
"provider_id": provider_id,
|
||||
"model_id": "test-model-v1",
|
||||
"alias": "Test Model",
|
||||
"context_window": 8192
|
||||
}),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::CREATED, "create model: {body}");
|
||||
let model_id = body["id"].as_str().unwrap();
|
||||
|
||||
// List models (paginated)
|
||||
let (status, list) = send(&app, get("/api/v1/models", &admin)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(list["items"].is_array(), "models list should be paginated: {list}");
|
||||
|
||||
// Get model
|
||||
let (status, _) = send(&app, get(&format!("/api/v1/models/{model_id}"), &admin)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
// Update model
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
patch(
|
||||
&format!("/api/v1/models/{model_id}"),
|
||||
&admin,
|
||||
serde_json::json!({ "alias": "Updated Alias" }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["alias"], "Updated Alias");
|
||||
|
||||
// Delete model
|
||||
let (status, _) = send(&app, delete(&format!("/api/v1/models/{model_id}"), &admin)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_create_forbidden_for_user() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "modeluser").await;
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
post(
|
||||
"/api/v1/models",
|
||||
&token,
|
||||
serde_json::json!({ "provider_id": "x", "model_id": "y", "alias": "Z" }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Account API Key
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_key_requires_existing_provider() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "keyuser").await;
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
post(
|
||||
"/api/v1/keys",
|
||||
&token,
|
||||
serde_json::json!({ "provider_id": "nonexistent", "key_value": "sk-test", "key_label": "Test" }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_key_lifecycle_with_provider() {
|
||||
let (app, pool) = build_test_app().await;
|
||||
let admin = admin_token(&app, &pool, "keyadmin").await;
|
||||
|
||||
// Create provider
|
||||
let (_, prov) = send(
|
||||
&app,
|
||||
post(
|
||||
"/api/v1/providers",
|
||||
&admin,
|
||||
serde_json::json!({ "name": "key-prov", "display_name": "Key Prov", "base_url": "https://api.test.com/v1" }),
|
||||
),
|
||||
).await;
|
||||
let provider_id = prov["id"].as_str().unwrap();
|
||||
|
||||
// Create key as regular user
|
||||
let user_token = register_token(&app, "keyowner").await;
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
post(
|
||||
"/api/v1/keys",
|
||||
&user_token,
|
||||
serde_json::json!({ "provider_id": provider_id, "key_value": "sk-test-key-123", "key_label": "My Key" }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::CREATED, "create key: {body}");
|
||||
let key_id = body["id"].as_str().unwrap();
|
||||
|
||||
// List keys (paginated)
|
||||
let (status, list) = send(&app, get("/api/v1/keys", &user_token)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(list["items"].is_array(), "keys list should be paginated: {list}");
|
||||
|
||||
// Delete key
|
||||
let (status, _) = send(&app, delete(&format!("/api/v1/keys/{key_id}"), &user_token)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Usage stats
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_stats_empty() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "usageuser").await;
|
||||
let (status, body) = send(&app, get("/api/v1/usage", &token)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["total_requests"], 0);
|
||||
}
|
||||
174
crates/zclaw-saas/tests/prompt_test.rs
Normal file
174
crates/zclaw-saas/tests/prompt_test.rs
Normal file
@@ -0,0 +1,174 @@
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::*;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// OTA check
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn prompt_ota_check() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "otacheck").await;
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
post(
|
||||
"/api/v1/prompts/check",
|
||||
&token,
|
||||
serde_json::json!({
|
||||
"device_id": "test-device-001",
|
||||
"versions": { "reflection": 0, "compaction": 0, "extraction": 0 }
|
||||
}),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(body["updates"].is_array());
|
||||
assert!(body["server_time"].is_string());
|
||||
// 3 builtin templates should have updates
|
||||
assert_eq!(body["updates"].as_array().unwrap().len(), 3);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Prompt list (admin only)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn prompt_list_requires_permission() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "promptuser").await;
|
||||
let (status, _) = send(&app, get("/api/v1/prompts", &token)).await;
|
||||
// Regular user has prompt:read via user role → may succeed or fail based on permissions
|
||||
// User role has prompt:read → should succeed
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prompt_list_shows_builtins() {
|
||||
let (app, pool) = build_test_app().await;
|
||||
let admin = admin_token(&app, &pool, "promptlist").await;
|
||||
let (status, body) = send(&app, get("/api/v1/prompts", &admin)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let items = body["items"].as_array().unwrap_or_else(|| {
|
||||
// Fallback: some handlers may return bare array
|
||||
body.as_array().unwrap()
|
||||
});
|
||||
assert!(items.len() >= 3, "should have 3 builtin templates, got {}: {body}", items.len());
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Prompt CRUD
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn prompt_create_and_get() {
|
||||
let (app, pool) = build_test_app().await;
|
||||
let admin = admin_token(&app, &pool, "promptcreate").await;
|
||||
|
||||
// Create
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
post(
|
||||
"/api/v1/prompts",
|
||||
&admin,
|
||||
serde_json::json!({
|
||||
"name": "test-prompt",
|
||||
"category": "test",
|
||||
"description": "A test prompt",
|
||||
"system_prompt": "You are a test assistant.",
|
||||
"user_prompt_template": "Hello {{name}}",
|
||||
"variables": [{ "name": "name", "required": true }]
|
||||
}),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::OK, "create prompt: {body}");
|
||||
|
||||
// Get by name
|
||||
let (status, body) = send(&app, get("/api/v1/prompts/test-prompt", &admin)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["name"], "test-prompt");
|
||||
|
||||
// Update metadata
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
put(
|
||||
"/api/v1/prompts/test-prompt",
|
||||
&admin,
|
||||
serde_json::json!({ "description": "Updated description" }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["description"], "Updated description");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Prompt versions
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn prompt_version_publish_and_list() {
|
||||
let (app, pool) = build_test_app().await;
|
||||
let admin = admin_token(&app, &pool, "promptver").await;
|
||||
|
||||
// Create template first
|
||||
send(
|
||||
&app,
|
||||
post(
|
||||
"/api/v1/prompts",
|
||||
&admin,
|
||||
serde_json::json!({
|
||||
"name": "versioned-prompt",
|
||||
"category": "test",
|
||||
"system_prompt": "Version 1 system prompt"
|
||||
}),
|
||||
),
|
||||
).await;
|
||||
|
||||
// Publish a new version
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
post(
|
||||
"/api/v1/prompts/versioned-prompt/versions",
|
||||
&admin,
|
||||
serde_json::json!({
|
||||
"system_prompt": "Version 2 system prompt",
|
||||
"changelog": "Updated for testing"
|
||||
}),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::OK, "publish version: {body}");
|
||||
|
||||
// List versions
|
||||
let (status, body) = send(&app, get("/api/v1/prompts/versioned-prompt/versions", &admin)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(body.is_array());
|
||||
assert!(body.as_array().unwrap().len() >= 2);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Prompt archive
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn prompt_archive() {
|
||||
let (app, pool) = build_test_app().await;
|
||||
let sa = super_admin_token(&app, &pool, "promptarchive").await;
|
||||
|
||||
// Create
|
||||
send(
|
||||
&app,
|
||||
post(
|
||||
"/api/v1/prompts",
|
||||
&sa,
|
||||
serde_json::json!({
|
||||
"name": "to-archive",
|
||||
"category": "test",
|
||||
"system_prompt": "Will be archived"
|
||||
}),
|
||||
),
|
||||
).await;
|
||||
|
||||
// Archive (delete)
|
||||
let (status, _) = send(&app, delete("/api/v1/prompts/to-archive", &sa)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
134
crates/zclaw-saas/tests/relay_test.rs
Normal file
134
crates/zclaw-saas/tests/relay_test.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::*;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Relay models list
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_models_list_empty() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "relaylist").await;
|
||||
let (status, body) = send(&app, get("/api/v1/relay/models", &token)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(body.is_array());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_chat_nonexistent_model() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "relaychat").await;
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
post(
|
||||
"/api/v1/relay/chat/completions",
|
||||
&token,
|
||||
serde_json::json!({
|
||||
"model": "nonexistent-model",
|
||||
"messages": [{ "role": "user", "content": "hello" }]
|
||||
}),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_tasks_list() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "relaytasks").await;
|
||||
let (status, body) = send(&app, get("/api/v1/relay/tasks", &token)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_task_get_nonexistent() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "relayget").await;
|
||||
let (status, _) = send(&app, get("/api/v1/relay/tasks/nonexistent-id", &token)).await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_retry_forbidden_for_user() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "retryuser").await;
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
post("/api/v1/relay/tasks/nonexistent/retry", &token, serde_json::json!({})),
|
||||
).await;
|
||||
// 404 (task not found) or 403 (forbidden) — either way not 200
|
||||
assert_ne!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Key pool management
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn key_pool_crud() {
|
||||
let (app, pool) = build_test_app().await;
|
||||
let admin = admin_token(&app, &pool, "kpooladmin").await;
|
||||
|
||||
// Create provider
|
||||
let (_, prov) = send(
|
||||
&app,
|
||||
post(
|
||||
"/api/v1/providers",
|
||||
&admin,
|
||||
serde_json::json!({ "name": "kpool-prov", "display_name": "KPool", "base_url": "https://api.kpool.com/v1" }),
|
||||
),
|
||||
).await;
|
||||
let provider_id = prov["id"].as_str().unwrap();
|
||||
|
||||
// Add key to pool
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
post(
|
||||
&format!("/api/v1/providers/{provider_id}/keys"),
|
||||
&admin,
|
||||
serde_json::json!({ "key_label": "Pool Key 1", "key_value": "sk-pool-key-001", "priority": 0 }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::OK, "add key to pool: {body}");
|
||||
let key_id = body["key_id"].as_str().unwrap();
|
||||
|
||||
// List pool keys
|
||||
let (status, keys) = send(&app, get(&format!("/api/v1/providers/{provider_id}/keys"), &admin)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(keys.is_array());
|
||||
|
||||
// Toggle key
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
put(
|
||||
&format!("/api/v1/providers/{provider_id}/keys/{key_id}/toggle"),
|
||||
&admin,
|
||||
serde_json::json!({ "active": false }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
// Delete key
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
delete(&format!("/api/v1/providers/{provider_id}/keys/{key_id}"), &admin),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn key_pool_forbidden_for_user() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "kpooluser").await;
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
post(
|
||||
"/api/v1/providers/nonexistent/keys",
|
||||
&token,
|
||||
serde_json::json!({ "key_label": "X", "key_value": "sk-x" }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
}
|
||||
170
crates/zclaw-saas/tests/role_test.rs
Normal file
170
crates/zclaw-saas/tests/role_test.rs
Normal file
@@ -0,0 +1,170 @@
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::*;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Role listing
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_roles_includes_system_roles() {
|
||||
let (app, pool) = build_test_app().await;
|
||||
let admin = admin_token(&app, &pool, "roleadmin").await;
|
||||
let (status, body) = send(&app, get("/api/v1/roles", &admin)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let roles = body.as_array().unwrap();
|
||||
let ids: Vec<&str> = roles.iter().map(|r| r["id"].as_str().unwrap()).collect();
|
||||
assert!(ids.contains(&"super_admin"));
|
||||
assert!(ids.contains(&"admin"));
|
||||
assert!(ids.contains(&"user"));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Role CRUD
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn role_crud() {
|
||||
let (app, pool) = build_test_app().await;
|
||||
let admin = admin_token(&app, &pool, "rolecrud").await;
|
||||
|
||||
// Create custom role
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
post(
|
||||
"/api/v1/roles",
|
||||
&admin,
|
||||
serde_json::json!({
|
||||
"id": "custom-role-1",
|
||||
"name": "Custom Role",
|
||||
"description": "A test role",
|
||||
"permissions": ["model:read", "relay:use"]
|
||||
}),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::CREATED, "create role: {body}");
|
||||
|
||||
// Get
|
||||
let (status, body) = send(&app, get("/api/v1/roles/custom-role-1", &admin)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["name"], "Custom Role");
|
||||
|
||||
// Update
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
put(
|
||||
"/api/v1/roles/custom-role-1",
|
||||
&admin,
|
||||
serde_json::json!({ "description": "Updated description" }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["description"], "Updated description");
|
||||
|
||||
// Delete custom role
|
||||
let (status, _) = send(&app, delete("/api/v1/roles/custom-role-1", &admin)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// System role protection
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn cannot_delete_system_role() {
|
||||
let (app, pool) = build_test_app().await;
|
||||
let admin = admin_token(&app, &pool, "sysrole").await;
|
||||
let (status, _) = send(&app, delete("/api/v1/roles/super_admin", &admin)).await;
|
||||
assert_ne!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Role creation forbidden for regular user
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn role_create_forbidden_for_user() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "rolenouser").await;
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
post(
|
||||
"/api/v1/roles",
|
||||
&token,
|
||||
serde_json::json!({ "id": "x", "name": "X", "permissions": [] }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Permission templates
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn permission_template_crud() {
|
||||
let (app, pool) = build_test_app().await;
|
||||
let admin = admin_token(&app, &pool, "tmpladmin").await;
|
||||
|
||||
// Create template
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
post(
|
||||
"/api/v1/permission-templates",
|
||||
&admin,
|
||||
serde_json::json!({
|
||||
"name": "Read-Only Template",
|
||||
"description": "Only read access",
|
||||
"permissions": ["model:read", "config:read", "prompt:read"]
|
||||
}),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::CREATED, "create template: {body}");
|
||||
let tmpl_id = body["id"].as_str().unwrap();
|
||||
|
||||
// List templates
|
||||
let (status, list) = send(&app, get("/api/v1/permission-templates", &admin)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(list.is_array());
|
||||
|
||||
// Get template
|
||||
let (status, _) = send(&app, get(&format!("/api/v1/permission-templates/{tmpl_id}"), &admin)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
// Delete template
|
||||
let (status, _) = send(&app, delete(&format!("/api/v1/permission-templates/{tmpl_id}"), &admin)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_permission_template() {
|
||||
let (app, pool) = build_test_app().await;
|
||||
let admin = admin_token(&app, &pool, "applyadmin").await;
|
||||
|
||||
// Create template
|
||||
let (_, tmpl_body) = send(
|
||||
&app,
|
||||
post(
|
||||
"/api/v1/permission-templates",
|
||||
&admin,
|
||||
serde_json::json!({ "name": "Apply Test", "permissions": ["model:read"] }),
|
||||
),
|
||||
).await;
|
||||
let tmpl_id = tmpl_body["id"].as_str().unwrap();
|
||||
|
||||
// Create a target user
|
||||
let (_, _, reg) = register(&app, "targetuser", "target@test.io", DEFAULT_PASSWORD).await;
|
||||
let target_id = reg["account"]["id"].as_str().unwrap();
|
||||
|
||||
// Apply template
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
post(
|
||||
&format!("/api/v1/permission-templates/{tmpl_id}/apply"),
|
||||
&admin,
|
||||
serde_json::json!({ "account_ids": [target_id] }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
123
crates/zclaw-saas/tests/telemetry_test.rs
Normal file
123
crates/zclaw-saas/tests/telemetry_test.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::*;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Report telemetry
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn telemetry_report_success() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "teluser").await;
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
post(
|
||||
"/api/v1/telemetry/report",
|
||||
&token,
|
||||
serde_json::json!({
|
||||
"device_id": "test-device-001",
|
||||
"app_version": "0.1.0",
|
||||
"entries": [{
|
||||
"model_id": "test-model-v1",
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 50,
|
||||
"latency_ms": 200,
|
||||
"success": true,
|
||||
"timestamp": now,
|
||||
"connection_mode": "tauri"
|
||||
}]
|
||||
}),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::OK, "report telemetry: {body}");
|
||||
assert_eq!(body["accepted"], 1);
|
||||
assert_eq!(body["rejected"], 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn telemetry_report_batch() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "telbatch").await;
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
let entries: Vec<serde_json::Value> = (0..5)
|
||||
.map(|i| {
|
||||
serde_json::json!({
|
||||
"model_id": format!("model-{i}"),
|
||||
"input_tokens": 100 + i,
|
||||
"output_tokens": 50 + i,
|
||||
"success": true,
|
||||
"timestamp": now,
|
||||
"connection_mode": "tauri"
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
post(
|
||||
"/api/v1/telemetry/report",
|
||||
&token,
|
||||
serde_json::json!({
|
||||
"device_id": "batch-device",
|
||||
"app_version": "0.1.0",
|
||||
"entries": entries
|
||||
}),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["accepted"], 5);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Stats query
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn telemetry_stats() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "telstats").await;
|
||||
let (status, _) = send(&app, get("/api/v1/telemetry/stats", &token)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn telemetry_daily() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "teldaily").await;
|
||||
let (status, _) = send(&app, get("/api/v1/telemetry/daily", &token)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Audit summary
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn telemetry_audit_report() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "telaudit").await;
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
post(
|
||||
"/api/v1/telemetry/audit",
|
||||
&token,
|
||||
serde_json::json!({
|
||||
"device_id": "audit-device",
|
||||
"entries": [{
|
||||
"action": "hand.trigger",
|
||||
"target": "Browser",
|
||||
"result": "success",
|
||||
"timestamp": now
|
||||
}]
|
||||
}),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
Reference in New Issue
Block a user