chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成

包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、
文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
This commit is contained in:
iven
2026-03-29 10:46:26 +08:00
parent 9a5fad2b59
commit 5fdf96c3f5
268 changed files with 22011 additions and 3886 deletions

View 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);
}

View 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);
}

View 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);
}

View 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
}

View 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);
}

View 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);
}

View 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);
}

View 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);
}

View 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);
}

View 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);
}