use fraiseql_server::{
error::{ErrorCode, ErrorResponse, GraphQLError},
routes::graphql::GraphQLRequest,
validation::RequestValidator,
};
use serde_json::json;
fn rbac_deny_error(field: &str) -> GraphQLError {
GraphQLError::forbidden().with_path(vec!["query".to_string(), field.to_string()])
}
fn rls_filter_no_rows() -> serde_json::Value {
json!({
"data": {
"users": []
}
})
}
fn masked_field(value: &str) -> String {
format!("****{}", &value[value.len().saturating_sub(4)..])
}
#[test]
fn test_rbac_deny_short_circuits_rls_and_masking() {
let error = rbac_deny_error("sensitiveData");
assert_eq!(error.code, ErrorCode::Forbidden);
assert!(error.path.is_some());
}
#[test]
fn test_rbac_allow_with_rls_filter_blocks_all() {
let empty_result = rls_filter_no_rows();
assert!(empty_result["data"]["users"].is_array());
let users_array = empty_result["data"]["users"].as_array().unwrap();
assert_eq!(users_array.len(), 0);
}
#[test]
fn test_rbac_allow_rls_allow_field_masked() {
let user_data = json!({
"id": "user-123",
"name": "John Doe",
"email": "****...@example.com", "phone": "****5678" });
let email = user_data["email"].as_str().unwrap();
assert!(email.contains("****"), "Email should be masked");
assert!(!email.contains("john"), "Masked field should not contain original");
let phone = user_data["phone"].as_str().unwrap();
assert!(phone.contains("****"), "Phone should be masked");
}
#[test]
fn test_rbac_allow_rls_allow_no_masking_for_public_fields() {
let user_data = json!({
"id": "user-123",
"name": "John Doe",
"role": "user" });
assert_eq!(user_data["name"], "John Doe");
assert_eq!(user_data["role"], "user");
}
#[test]
fn test_security_stack_evaluation_order() {
fn rbac_check(role: &str, operation: &str) -> Result<(), &'static str> {
match (role, operation) {
("admin", _) => Ok(()), ("user", "read") => Ok(()), ("user", "delete") => Err("Permission denied"),
_ => Err("Unauthorized"),
}
}
fn rls_filter(role: &str, _user_id: &str) -> Vec<String> {
match role {
"admin" => ["user-1", "user-2", "user-3"].iter().map(|s| s.to_string()).collect(),
"user" => ["user-1"].iter().map(|s| s.to_string()).collect(), _ => vec![],
}
}
fn apply_field_masking(role: &str, field_name: &str) -> bool {
match role {
"admin" => false, _ => matches!(field_name, "password" | "ssn" | "api_key"),
}
}
let role = "admin";
assert!(rbac_check(role, "read").is_ok(), "RBAC should allow");
let visible_rows = rls_filter(role, "user-1");
assert!(!visible_rows.is_empty(), "RLS should return rows");
assert!(!apply_field_masking(role, "password"), "Masking should not apply for admin");
let role = "user";
assert!(rbac_check(role, "read").is_ok(), "RBAC should allow read");
let visible_rows = rls_filter(role, "user-1");
assert_eq!(visible_rows.len(), 1, "RLS should return 1 row for user");
assert!(apply_field_masking(role, "password"), "Masking should apply");
assert!(rbac_check("user", "delete").is_err(), "RBAC should deny delete");
}
#[test]
fn test_error_response_when_rbac_denies() {
let error =
GraphQLError::forbidden().with_path(vec!["user".to_string(), "sensitiveField".to_string()]);
let response = ErrorResponse::from_error(error);
assert_eq!(response.errors[0].code, ErrorCode::Forbidden);
assert!(response.errors[0].path.is_some());
assert_eq!(response.errors[0].message, "Access denied");
}
#[test]
fn test_empty_result_when_rls_filters_all_rows() {
let response = json!({
"data": {
"users": []
}
});
assert!(response["data"]["users"].is_array());
assert_eq!(response["data"]["users"].as_array().unwrap().len(), 0);
}
#[test]
fn test_field_masking_pattern_consistency() {
let sensitive_fields = vec![
("password", "mysecretpass123"),
("ssn", "123-45-6789"),
("apiKey", "sk_live_1234567890abcdef"),
];
for (field_name, original_value) in sensitive_fields {
let masked = masked_field(original_value);
assert!(masked.starts_with("****"), "Masked field {} should start with ****", field_name);
assert!(
!masked.contains(&original_value[0..3]),
"Masked field {} should not contain original prefix",
field_name
);
}
}
#[test]
fn test_partial_masking_of_sensitive_fields() {
let credit_card = "4532-1488-0343-6467";
let masked = format!("****{}", &credit_card[credit_card.len().saturating_sub(4)..]);
assert!(masked.ends_with("6467"), "Should preserve last 4 digits");
assert!(!masked.contains("4532"), "Should mask first digits");
assert_eq!(masked, "****6467");
}
#[test]
fn test_security_stack_with_nested_fields() {
let query = "query {
user(id: \"123\") {
id
name
profile {
bio
phone # Sensitive - should be masked
}
settings {
apiKey # Sensitive - should be masked
theme
}
}
}";
let validator = RequestValidator::new();
assert!(validator.validate_query(query).is_ok(), "Query should be valid");
}
#[test]
fn test_role_specific_field_visibility() {
fn get_visible_fields(role: &str) -> Vec<&'static str> {
match role {
"admin" => vec!["id", "name", "email", "password", "role", "created_at"],
"user" => vec!["id", "name", "email"],
"guest" => vec!["id", "name"],
_ => vec![],
}
}
let admin_fields = get_visible_fields("admin");
let user_fields = get_visible_fields("user");
let guest_fields = get_visible_fields("guest");
assert!(admin_fields.contains(&"password"));
assert!(admin_fields.contains(&"role"));
assert!(user_fields.contains(&"email"));
assert!(!user_fields.contains(&"password"));
assert!(!user_fields.contains(&"role"));
assert_eq!(guest_fields.len(), 2);
assert!(!guest_fields.contains(&"email"));
}
#[test]
fn test_tenant_isolation_via_rls() {
fn get_tenant_rows(tenant_id: &str, _user_id: &str) -> Vec<String> {
match tenant_id {
"tenant-a" => ["user-1-a", "user-2-a"].iter().map(|s| s.to_string()).collect(),
"tenant-b" => ["user-1-b", "user-2-b"].iter().map(|s| s.to_string()).collect(),
_ => vec![],
}
}
let tenant_a_rows = get_tenant_rows("tenant-a", "user-1");
let tenant_b_rows = get_tenant_rows("tenant-b", "user-1");
assert!(
!tenant_a_rows.iter().any(|r| r.contains("-b")),
"Tenant A should not see Tenant B rows"
);
assert!(
!tenant_b_rows.iter().any(|r| r.contains("-a")),
"Tenant B should not see Tenant A rows"
);
}
#[test]
fn test_combined_rbac_rls_filtering() {
fn combined_filter(role: &str, tenant_id: &str, user_id: &str) -> Vec<String> {
if !matches!(role, "admin" | "user") {
return vec![]; }
let all_rows = ["user-1", "user-2", "user-3", "user-4"];
let tenant_rows: Vec<_> = all_rows
.iter()
.filter(|r| {
(tenant_id == "a" && (r.starts_with("user-1") || r.starts_with("user-2")))
|| (tenant_id == "b" && r.starts_with("user-3"))
})
.map(|s| s.to_string())
.collect();
if role == "user" {
return tenant_rows
.into_iter()
.filter(|r| r == user_id || r.contains("-1")) .collect();
}
tenant_rows
}
let admin_rows = combined_filter("admin", "a", "");
assert_eq!(admin_rows.len(), 2);
let user_rows = combined_filter("user", "a", "user-1");
assert!(user_rows.len() <= 2);
let invalid_rows = combined_filter("invalid", "a", "");
assert_eq!(invalid_rows.len(), 0);
}
#[test]
fn test_error_hierarchy_for_security_layers() {
let rbac_error = GraphQLError::forbidden().with_path(vec!["secretField".to_string()]);
assert_eq!(rbac_error.code, ErrorCode::Forbidden);
let rls_result = json!({
"data": {
"users": []
}
});
assert!(rls_result["data"]["users"].is_array());
assert_eq!(rls_result["data"]["users"].as_array().unwrap().len(), 0);
let validation_error = GraphQLError::validation("Field 'maskedPassword' doesn't exist");
assert_eq!(validation_error.code, ErrorCode::ValidationError);
}
#[test]
fn test_security_stack_performance_order() {
}
#[test]
fn test_no_information_leakage_on_rbac_denial() {
let error = GraphQLError::forbidden();
assert_eq!(error.message, "Access denied");
assert!(!error.message.contains("field"), "Don't reveal field names");
assert!(!error.message.contains("permission"), "Don't reveal permission model");
assert!(!error.message.contains("row"), "Don't reveal RLS rules");
}
#[test]
fn test_field_masking_independent_of_rbac() {
let admin_sees_email = "john@example.com";
let admin_masked_password = "****";
assert_eq!(admin_sees_email, "john@example.com"); assert_eq!(admin_masked_password, "****"); }
#[test]
fn test_graphql_request_with_security_context() {
let request = GraphQLRequest {
query: "query { user(id: \"123\") { id name email } }".to_string(),
variables: None,
operation_name: None,
};
let validator = RequestValidator::new();
assert!(validator.validate_query(&request.query).is_ok());
}
#[test]
fn test_multi_tenant_field_masking() {
fn get_masked_fields(tenant_type: &str, role: &str) -> Vec<&'static str> {
match (tenant_type, role) {
("healthcare", _) => vec!["ssn", "dob", "medical_history"], ("finance", _) => vec!["account_number", "routing_number"], ("standard", "admin") => vec![], ("standard", "user") => vec!["email", "phone"], _ => vec![],
}
}
let healthcare_masked = get_masked_fields("healthcare", "user");
assert!(healthcare_masked.contains(&"ssn"), "Healthcare should mask SSN");
assert!(
healthcare_masked.contains(&"medical_history"),
"Healthcare should mask medical data"
);
let finance_masked = get_masked_fields("finance", "user");
assert!(
finance_masked.contains(&"account_number"),
"Finance should mask account numbers"
);
}