test(saas): add 36 security/validation/permission tests (184 total, 0 failures)
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
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.
This commit is contained in:
237
crates/zclaw-saas/tests/account_security_test.rs
Normal file
237
crates/zclaw-saas/tests/account_security_test.rs
Normal file
@@ -0,0 +1,237 @@
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::*;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Role management: admin can change another user's role
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_can_change_user_role_to_admin() {
|
||||
let (app, pool) = build_test_app().await;
|
||||
let admin = admin_token(&app, &pool, "rolechangeadmin").await;
|
||||
let user_token = register_token(&app, "targetroleuser").await;
|
||||
|
||||
// Get user's account ID
|
||||
let (_, me) = send(&app, get("/api/v1/auth/me", &user_token)).await;
|
||||
let user_id = me["id"].as_str().unwrap();
|
||||
|
||||
// Admin promotes user to admin
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
patch(
|
||||
&format!("/api/v1/accounts/{user_id}"),
|
||||
&admin,
|
||||
serde_json::json!({ "role": "admin" }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::OK, "admin should be able to change role: {body}");
|
||||
assert_eq!(body["role"], "admin");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_can_demote_user_role() {
|
||||
let (app, pool) = build_test_app().await;
|
||||
let admin = admin_token(&app, &pool, "demoteadmin").await;
|
||||
|
||||
// Create a second admin
|
||||
let second_admin = admin_token(&app, &pool, "secondadmin").await;
|
||||
let (_, me) = send(&app, get("/api/v1/auth/me", &second_admin)).await;
|
||||
let second_id = me["id"].as_str().unwrap();
|
||||
|
||||
// Demote to user
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
patch(
|
||||
&format!("/api/v1/accounts/{second_id}"),
|
||||
&admin,
|
||||
serde_json::json!({ "role": "user" }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::OK, "admin should be able to demote: {body}");
|
||||
assert_eq!(body["role"], "user");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Security: user cannot escalate own role via self-update
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_cannot_escalate_role_via_self_update() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "escalateuser").await;
|
||||
|
||||
let (_, me) = send(&app, get("/api/v1/auth/me", &token)).await;
|
||||
let user_id = me["id"].as_str().unwrap();
|
||||
|
||||
// Try to escalate role to admin
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
patch(
|
||||
&format!("/api/v1/accounts/{user_id}"),
|
||||
&token,
|
||||
serde_json::json!({ "role": "admin", "display_name": "Updated" }),
|
||||
),
|
||||
).await;
|
||||
// The update may succeed but role should be ignored
|
||||
if status == StatusCode::OK {
|
||||
// Verify role was NOT changed
|
||||
let (_, updated) = send(&app, get(&format!("/api/v1/accounts/{user_id}"), &token)).await;
|
||||
assert_ne!(updated["role"], "admin", "user should NOT be able to escalate own role: {updated}");
|
||||
assert_eq!(updated["display_name"], "Updated", "display_name should still update");
|
||||
}
|
||||
// If the endpoint rejects the role field entirely, that's also acceptable
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Account status management: admin can disable/enable
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_can_disable_account() {
|
||||
let (app, pool) = build_test_app().await;
|
||||
let admin = admin_token(&app, &pool, "disableadmin").await;
|
||||
let user_token = register_token(&app, "disabletarget").await;
|
||||
|
||||
let (_, me) = send(&app, get("/api/v1/auth/me", &user_token)).await;
|
||||
let user_id = me["id"].as_str().unwrap();
|
||||
|
||||
// Admin disables user
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
patch(
|
||||
&format!("/api/v1/accounts/{user_id}/status"),
|
||||
&admin,
|
||||
serde_json::json!({ "status": "disabled" }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::OK, "admin should be able to disable account: {body}");
|
||||
|
||||
// Disabled user cannot log in
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
post_public(
|
||||
"/api/v1/auth/login",
|
||||
serde_json::json!({ "username": "disabletarget", "password": DEFAULT_PASSWORD }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN, "disabled account should not be able to log in");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_can_reenable_account() {
|
||||
let (app, pool) = build_test_app().await;
|
||||
let admin = admin_token(&app, &pool, "reenableadmin").await;
|
||||
register(&app, "reenabletarget", "reenable@test.io", DEFAULT_PASSWORD).await;
|
||||
|
||||
// Disable first
|
||||
let (_, me) = send(&app, get("/api/v1/auth/me", &admin)).await;
|
||||
// Get the target user's ID from accounts list
|
||||
let (_, list) = send(&app, get("/api/v1/accounts", &admin)).await;
|
||||
let items = list["items"].as_array().expect("accounts list");
|
||||
let target_id = items.iter()
|
||||
.find(|a| a["username"] == "reenabletarget")
|
||||
.map(|a| a["id"].as_str().unwrap())
|
||||
.expect("should find target user");
|
||||
|
||||
// Disable
|
||||
send(
|
||||
&app,
|
||||
patch(
|
||||
&format!("/api/v1/accounts/{target_id}/status"),
|
||||
&admin,
|
||||
serde_json::json!({ "status": "disabled" }),
|
||||
),
|
||||
).await;
|
||||
|
||||
// Re-enable
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
patch(
|
||||
&format!("/api/v1/accounts/{target_id}/status"),
|
||||
&admin,
|
||||
serde_json::json!({ "status": "active" }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::OK, "admin should be able to re-enable account");
|
||||
|
||||
// User can log in again
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
post_public(
|
||||
"/api/v1/auth/login",
|
||||
serde_json::json!({ "username": "reenabletarget", "password": DEFAULT_PASSWORD }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::OK, "re-enabled account should be able to log in");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_cannot_disable_others() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token = register_token(&app, "disableattacker").await;
|
||||
let victim_token = register_token(&app, "disablevictim").await;
|
||||
|
||||
let (_, me) = send(&app, get("/api/v1/auth/me", &victim_token)).await;
|
||||
let victim_id = me["id"].as_str().unwrap();
|
||||
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
patch(
|
||||
&format!("/api/v1/accounts/{victim_id}/status"),
|
||||
&token,
|
||||
serde_json::json!({ "status": "disabled" }),
|
||||
),
|
||||
).await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN, "regular user should not be able to disable others");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Cross-account access control
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_cannot_view_other_account_details() {
|
||||
let (app, _pool) = build_test_app().await;
|
||||
let token_a = register_token(&app, "viewerA").await;
|
||||
let token_b = register_token(&app, "viewerB").await;
|
||||
|
||||
let (_, me_b) = send(&app, get("/api/v1/auth/me", &token_b)).await;
|
||||
let b_id = me_b["id"].as_str().unwrap();
|
||||
|
||||
let (status, _) = send(&app, get(&format!("/api/v1/accounts/{b_id}"), &token_a)).await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN, "user should not see another user's account details");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_can_view_any_account() {
|
||||
let (app, pool) = build_test_app().await;
|
||||
let admin = admin_token(&app, &pool, "viewadmin").await;
|
||||
let user_token = register_token(&app, "viewtarget").await;
|
||||
|
||||
let (_, me) = send(&app, get("/api/v1/auth/me", &user_token)).await;
|
||||
let user_id = me["id"].as_str().unwrap();
|
||||
|
||||
let (status, body) = send(&app, get(&format!("/api/v1/accounts/{user_id}"), &admin)).await;
|
||||
assert_eq!(status, StatusCode::OK, "admin should be able to view any account: {body}");
|
||||
assert_eq!(body["username"], "viewtarget");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Operation logs are admin-only
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn operation_logs_admin_can_list() {
|
||||
let (app, pool) = build_test_app().await;
|
||||
let admin = admin_token(&app, &pool, "logadmin").await;
|
||||
|
||||
// Perform an action that generates a log
|
||||
send(&app, get("/api/v1/accounts", &admin)).await;
|
||||
|
||||
let (status, body) = send(&app, get("/api/v1/logs/operations", &admin)).await;
|
||||
assert_eq!(status, StatusCode::OK, "admin should see operation logs: {body}");
|
||||
// Should be paginated
|
||||
assert!(body["items"].is_array() || body.is_array(), "logs should be array or paginated");
|
||||
}
|
||||
Reference in New Issue
Block a user