Files
zclaw_openfang/crates/zclaw-saas/tests/permission_matrix_test.rs
iven c37c7218c2
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
test(saas): add 36 security/validation/permission tests (184 total, 0 failures)
New test files:
- auth_security_test.rs (12): account lockout DB state, lockout reset,
  password version invalidation, disabled account, refresh token
  revocation, boundary validation (username/password), role enforcement,
  TOTP 2FA flow
- account_security_test.rs (9): role management, privilege escalation
  prevention, account disable/enable, cross-account access control,
  operation logs
- relay_validation_test.rs (8): input validation (missing fields, empty
  messages, invalid roles), disabled provider, model listing, task
  isolation
- permission_matrix_test.rs (7): super_admin full access, user allowed/
  forbidden endpoints, public endpoints, unauthenticated rejection,
  API token lifecycle

Discovered: account lockout runtime check broken — handlers.rs:213
parse_from_rfc3339 fails on PostgreSQL TIMESTAMPTZ::TEXT format,
silently skipping lockout. DB state is correct but login not rejected.
2026-04-10 08:11:02 +08:00

256 lines
9.8 KiB
Rust

mod common;
use axum::http::StatusCode;
use common::*;
/// Comprehensive permission matrix test.
/// Verifies that each role can only access the endpoints they are authorized for.
/// This is the single most important security test for the system.
// ═══════════════════════════════════════════════════════════════════
// Permission matrix: super_admin can access everything
// ═══════════════════════════════════════════════════════════════════
#[tokio::test]
async fn super_admin_can_access_all_protected_endpoints() {
let (app, pool) = build_test_app().await;
let sa = super_admin_token(&app, &pool, "sa_matrix").await;
let get_endpoints: Vec<&str> = vec![
"/api/v1/auth/me",
"/api/v1/accounts",
"/api/v1/providers",
"/api/v1/models",
"/api/v1/relay/models",
"/api/v1/relay/tasks",
"/api/v1/roles",
"/api/v1/logs/operations",
"/api/v1/stats/dashboard",
"/api/v1/knowledge/categories",
"/api/v1/knowledge/analytics/overview",
"/api/v1/billing/subscription",
"/api/v1/billing/plans",
"/api/v1/usage",
"/api/v1/tokens",
"/api/v1/keys",
"/api/v1/prompts",
"/api/v1/config/items",
"/api/v1/agent-templates",
"/api/v1/scheduler/tasks",
"/api/v1/devices",
];
for path in &get_endpoints {
let (status, body) = send(&app, get(path, &sa)).await;
assert!(
status != StatusCode::UNAUTHORIZED && status != StatusCode::FORBIDDEN,
"super_admin should access GET {path}: got {status}, body={body}"
);
}
// POST endpoints that should not be auth-rejected (may fail at validation)
let post_endpoints: Vec<(&str, serde_json::Value)> = vec![
("/api/v1/providers", serde_json::json!({ "name": "test-prov", "display_name": "Test", "base_url": "https://test.com/v1" })),
];
for (path, body_val) in &post_endpoints {
let (status, _) = send(&app, post(path, &sa, body_val.clone())).await;
assert!(
status != StatusCode::UNAUTHORIZED && status != StatusCode::FORBIDDEN,
"super_admin should access POST {path}: got {status}"
);
}
}
// ═══════════════════════════════════════════════════════════════════
// Permission matrix: regular user restrictions
// ═══════════════════════════════════════════════════════════════════
#[tokio::test]
async fn user_can_access_own_endpoints() {
let (app, _pool) = build_test_app().await;
let token = register_token(&app, "user_allowed").await;
// Endpoints that a regular user SHOULD be able to access
let allowed: Vec<&str> = vec![
"/api/v1/auth/me",
"/api/v1/relay/models",
"/api/v1/relay/tasks",
"/api/v1/billing/plans",
"/api/v1/billing/subscription",
"/api/v1/usage",
"/api/v1/tokens",
"/api/v1/keys",
"/api/v1/devices",
];
for path in &allowed {
let (status, body) = send(&app, get(*path, &token)).await;
assert!(
status != StatusCode::UNAUTHORIZED && status != StatusCode::FORBIDDEN,
"user should access GET {path}: got {status}, body={body}"
);
}
}
#[tokio::test]
async fn user_cannot_access_admin_endpoints() {
let (app, _pool) = build_test_app().await;
let token = register_token(&app, "user_blocked").await;
// Endpoints that a regular user should NOT be able to access
// Note: prompts, config/items, agent-templates, scheduler/tasks
// are readable by all users (only writes are admin-only)
let forbidden_get: Vec<&str> = vec![
"/api/v1/accounts",
"/api/v1/roles",
"/api/v1/logs/operations",
"/api/v1/stats/dashboard",
"/api/v1/knowledge/categories",
];
for path in &forbidden_get {
let (status, resp_body) = send(&app, get(*path, &token)).await;
assert!(
status == StatusCode::FORBIDDEN || status == StatusCode::UNAUTHORIZED,
"user should NOT access GET {path}: got {status}, body={resp_body}"
);
}
let forbidden_post: Vec<(&str, serde_json::Value)> = vec![
("/api/v1/providers", serde_json::json!({ "name": "x", "display_name": "X", "base_url": "https://x.com" })),
("/api/v1/models", serde_json::json!({ "provider_id": "x", "model_id": "y", "alias": "Z" })),
("/api/v1/roles", serde_json::json!({ "id": "x", "name": "X", "permissions": [] })),
("/api/v1/knowledge/categories", serde_json::json!({ "name": "test" })),
("/api/v1/relay/tasks/nonexistent/retry", serde_json::json!({})),
];
for (path, body_val) in &forbidden_post {
let (status, resp_body) = send(&app, post(*path, &token, body_val.clone())).await;
assert!(
status == StatusCode::FORBIDDEN || status == StatusCode::UNAUTHORIZED,
"user should NOT access POST {path}: got {status}, body={resp_body}"
);
}
}
// ═══════════════════════════════════════════════════════════════════
// Permission matrix: unauthenticated access
// ═══════════════════════════════════════════════════════════════════
#[tokio::test]
async fn public_endpoints_accessible_without_auth() {
let (app, _pool) = build_test_app().await;
// Public endpoints that should work without any token
let public = vec![
("/api/health", "GET", None),
("/api/v1/auth/register", "POST", Some(serde_json::json!({
"username": "publictest", "email": "public@test.io", "password": DEFAULT_PASSWORD
}))),
("/api/v1/billing/plans", "GET", None),
];
for (path, method, body) in &public {
let req = match (*body).clone() {
Some(b) => post_public(*path, b),
None => axum::http::Request::builder()
.method(*method)
.uri(*path)
.body(axum::body::Body::empty())
.unwrap(),
};
let (status, resp_body) = send(&app, req).await;
assert_ne!(
status,
StatusCode::UNAUTHORIZED,
"public endpoint {method} {path} should not require auth: got {status}, body={resp_body}"
);
}
}
#[tokio::test]
async fn protected_endpoints_reject_unauthenticated() {
let (app, _pool) = build_test_app().await;
let protected: Vec<&str> = vec![
"/api/v1/auth/me",
"/api/v1/accounts",
"/api/v1/providers",
"/api/v1/models",
"/api/v1/relay/models",
"/api/v1/relay/tasks",
"/api/v1/roles",
"/api/v1/knowledge/categories",
"/api/v1/prompts",
"/api/v1/config/items",
"/api/v1/usage",
"/api/v1/tokens",
"/api/v1/keys",
"/api/v1/devices",
];
for path in &protected {
let req = axum::http::Request::builder()
.uri(*path)
.body(axum::body::Body::empty())
.unwrap();
let (status, _) = send(&app, req).await;
assert_eq!(
status,
StatusCode::UNAUTHORIZED,
"unauthenticated request to {path} should be rejected with 401"
);
}
}
// ═══════════════════════════════════════════════════════════════════
// API Token authentication
// ═══════════════════════════════════════════════════════════════════
#[tokio::test]
async fn api_token_works_like_jwt() {
let (app, _pool) = build_test_app().await;
let token = register_token(&app, "apitokenuser").await;
// Create API token
let (_, body) = send(
&app,
post("/api/v1/tokens", &token,
serde_json::json!({ "name": "test-api-token", "permissions": ["model:read", "relay:use"] }),
),
).await;
let raw_token = body["token"].as_str().unwrap();
// API token should authenticate for /me
let (status, me) = send(&app, get("/api/v1/auth/me", raw_token)).await;
assert_eq!(status, StatusCode::OK, "API token should authenticate: {me}");
assert_eq!(me["username"], "apitokenuser");
}
#[tokio::test]
async fn api_token_revoked_no_longer_works() {
let (app, _pool) = build_test_app().await;
let token = register_token(&app, "revokeapitoken").await;
// Create + revoke
let (_, create_body) = send(
&app,
post("/api/v1/tokens", &token,
serde_json::json!({ "name": "to-revoke", "permissions": ["model:read"] }),
),
).await;
let raw_token = create_body["token"].as_str().unwrap();
let token_id = create_body["id"].as_str().unwrap();
// Verify it works
let (status, _) = send(&app, get("/api/v1/auth/me", raw_token)).await;
assert_eq!(status, StatusCode::OK);
// Revoke
send(&app, delete(&format!("/api/v1/tokens/{token_id}"), &token)).await;
// Should no longer work
let (status, _) = send(&app, get("/api/v1/auth/me", raw_token)).await;
assert_eq!(status, StatusCode::UNAUTHORIZED, "revoked API token should not authenticate");
}