#![allow(clippy::unwrap_used)] #![allow(clippy::default_trait_access)] use std::collections::HashMap;
use chrono::Utc;
use fraiseql_core::{
runtime::{can_access_field, filter_fields},
schema::{
CompiledSchema, FieldDefinition, FieldDenyPolicy, FieldType, RoleDefinition,
SecurityConfig, TypeDefinition,
},
security::SecurityContext,
};
fn create_schema_with_mixed_fields() -> CompiledSchema {
let user_type = TypeDefinition {
name: "User".into(),
fields: vec![
FieldDefinition {
name: "id".into(),
field_type: FieldType::Int,
nullable: false,
default_value: None,
description: Some("Public ID".to_string()),
vector_config: None,
alias: None,
deprecation: None,
requires_scope: None,
on_deny: FieldDenyPolicy::default(),
encryption: None,
},
FieldDefinition {
name: "publicInfo".into(),
field_type: FieldType::String,
nullable: false,
default_value: None,
description: Some("Public information".to_string()),
vector_config: None,
alias: None,
deprecation: None,
requires_scope: None,
on_deny: FieldDenyPolicy::default(),
encryption: None,
},
FieldDefinition {
name: "email".into(),
field_type: FieldType::String,
nullable: false,
default_value: None,
description: None,
vector_config: None,
alias: None,
deprecation: None,
requires_scope: Some("read:User.email".to_string()),
on_deny: FieldDenyPolicy::default(),
encryption: None,
},
FieldDefinition {
name: "phone".into(),
field_type: FieldType::String,
nullable: true,
default_value: None,
description: None,
vector_config: None,
alias: None,
deprecation: None,
requires_scope: Some("read:User.phone".to_string()),
on_deny: FieldDenyPolicy::default(),
encryption: None,
},
FieldDefinition {
name: "ssn".into(),
field_type: FieldType::String,
nullable: true,
default_value: None,
description: None,
vector_config: None,
alias: None,
deprecation: None,
requires_scope: Some("admin:*".to_string()),
on_deny: FieldDenyPolicy::default(),
encryption: None,
},
FieldDefinition {
name: "bankAccount".into(),
field_type: FieldType::String,
nullable: true,
default_value: None,
description: None,
vector_config: None,
alias: None,
deprecation: None,
requires_scope: Some("admin:*".to_string()),
on_deny: FieldDenyPolicy::default(),
encryption: None,
},
],
description: Some("User with mixed access levels".to_string()),
sql_source: "users".into(),
jsonb_column: String::new(),
sql_projection_hint: None,
implements: vec![],
requires_role: None,
is_error: false,
relay: false,
relationships: vec![],
};
let mut security_config = SecurityConfig::new();
security_config
.add_role(RoleDefinition::new("viewer".to_string(), vec!["read:User.*".to_string()]));
security_config.add_role(RoleDefinition::new(
"restricted".to_string(),
vec![], ));
security_config.add_role(RoleDefinition::new("admin".to_string(), vec!["*".to_string()]));
security_config.default_role = Some("viewer".to_string());
CompiledSchema {
types: vec![user_type],
queries: vec![],
mutations: vec![],
enums: vec![],
input_types: vec![],
interfaces: vec![],
unions: vec![],
subscriptions: vec![],
directives: vec![],
observers: vec![],
fact_tables: HashMap::default(),
federation: None,
security: Some(security_config),
observers_config: None,
subscriptions_config: None,
validation_config: None,
debug_config: None,
mcp_config: None,
schema_format_version: None,
schema_sdl: None,
custom_scalars: Default::default(),
..CompiledSchema::default()
}
}
fn create_context(role: &str) -> SecurityContext {
SecurityContext {
user_id: format!("user-{}", role),
roles: vec![role.to_string()],
tenant_id: None,
scopes: vec![],
attributes: HashMap::new(),
request_id: "req-error".to_string(),
ip_address: None,
authenticated_at: Utc::now(),
expires_at: Utc::now() + chrono::Duration::hours(1),
issuer: None,
audience: None,
}
}
#[test]
fn test_field_filtering_partial_access() {
let schema = create_schema_with_mixed_fields();
let user_type = schema.types.iter().find(|t| t.name == "User").unwrap();
let security_config = schema.security.as_ref().expect("security config present").clone();
let viewer_context = create_context("viewer");
let all_fields = &user_type.fields;
let accessible = filter_fields(&viewer_context, &security_config, all_fields);
let names: Vec<&str> = accessible.iter().map(|f| f.name.as_str()).collect();
assert!(names.contains(&"id"));
assert!(names.contains(&"publicInfo"));
assert!(names.contains(&"email"));
assert!(names.contains(&"phone"));
assert!(!names.contains(&"ssn"));
assert!(!names.contains(&"bankAccount"));
assert_eq!(accessible.len(), 4, "Should have 4 accessible fields");
}
#[test]
fn test_field_filtering_all_fields_denied() {
let schema = create_schema_with_mixed_fields();
let user_type = schema.types.iter().find(|t| t.name == "User").unwrap();
let security_config = schema.security.as_ref().expect("security config present").clone();
let restricted_context = create_context("restricted");
let accessible = filter_fields(&restricted_context, &security_config, &user_type.fields);
let names: Vec<&str> = accessible.iter().map(|f| f.name.as_str()).collect();
assert!(names.contains(&"id"));
assert!(names.contains(&"publicInfo"));
assert!(!names.contains(&"email"));
assert!(!names.contains(&"phone"));
assert!(!names.contains(&"ssn"));
assert!(!names.contains(&"bankAccount"));
assert_eq!(accessible.len(), 2, "Should only have 2 public fields");
}
#[test]
fn test_field_filtering_empty_request() {
let schema = create_schema_with_mixed_fields();
let security_config = schema.security.as_ref().expect("security config present").clone();
let viewer_context = create_context("viewer");
let empty_fields = vec![];
let accessible = filter_fields(&viewer_context, &security_config, &empty_fields);
assert_eq!(accessible.len(), 0, "Empty input should return empty");
}
#[test]
fn test_field_filtering_respects_field_order() {
let schema = create_schema_with_mixed_fields();
let user_type = schema.types.iter().find(|t| t.name == "User").unwrap();
let security_config = schema.security.as_ref().expect("security config present").clone();
let viewer_context = create_context("viewer");
let ordered_request = vec![
user_type.fields[4].clone(), user_type.fields[2].clone(), user_type.fields[0].clone(), user_type.fields[5].clone(), user_type.fields[1].clone(), ];
let accessible = filter_fields(&viewer_context, &security_config, &ordered_request);
let names: Vec<&str> = accessible.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, vec!["email", "id", "publicInfo"]);
}
#[test]
fn test_field_filtering_duplicate_requests() {
let schema = create_schema_with_mixed_fields();
let user_type = schema.types.iter().find(|t| t.name == "User").unwrap();
let security_config = schema.security.as_ref().expect("security config present").clone();
let viewer_context = create_context("viewer");
let duplicates = vec![
user_type.fields[0].clone(), user_type.fields[0].clone(), user_type.fields[2].clone(), user_type.fields[2].clone(), ];
let accessible = filter_fields(&viewer_context, &security_config, &duplicates);
let names: Vec<&str> = accessible.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names.len(), 4, "Should preserve duplicates");
assert_eq!(names, vec!["id", "id", "email", "email"]);
}
#[test]
fn test_field_access_denied_for_single_field() {
let schema = create_schema_with_mixed_fields();
let user_type = schema.types.iter().find(|t| t.name == "User").unwrap();
let security_config = schema.security.as_ref().expect("security config present").clone();
let restricted_context = create_context("restricted");
let email_field = user_type.fields.iter().find(|f| f.name == "email").unwrap();
let ssn_field = user_type.fields.iter().find(|f| f.name == "ssn").unwrap();
let can_access_email = can_access_field(&restricted_context, &security_config, email_field);
let can_access_ssn = can_access_field(&restricted_context, &security_config, ssn_field);
assert!(!can_access_email, "Restricted user cannot access email");
assert!(!can_access_ssn, "Restricted user cannot access ssn");
}
#[test]
fn test_field_access_public_fields_always_allowed() {
let schema = create_schema_with_mixed_fields();
let user_type = schema.types.iter().find(|t| t.name == "User").unwrap();
let security_config = schema.security.as_ref().expect("security config present").clone();
let restricted_context = create_context("restricted");
let id_field = user_type.fields.iter().find(|f| f.name == "id").unwrap();
let public_info_field = user_type.fields.iter().find(|f| f.name == "publicInfo").unwrap();
let can_access_id = can_access_field(&restricted_context, &security_config, id_field);
let can_access_public_info =
can_access_field(&restricted_context, &security_config, public_info_field);
assert!(can_access_id, "Public id should be accessible");
assert!(can_access_public_info, "Public publicInfo should be accessible");
}
#[test]
fn test_field_filtering_with_null_fields() {
let schema = create_schema_with_mixed_fields();
let user_type = schema.types.iter().find(|t| t.name == "User").unwrap();
let security_config = schema.security.as_ref().expect("security config present").clone();
let viewer_context = create_context("viewer");
let accessible = filter_fields(&viewer_context, &security_config, &user_type.fields);
let names: Vec<&str> = accessible.iter().map(|f| f.name.as_str()).collect();
assert!(names.contains(&"email"));
assert!(names.contains(&"phone"));
}
#[test]
fn test_field_filtering_consistency_across_calls() {
let schema = create_schema_with_mixed_fields();
let user_type = schema.types.iter().find(|t| t.name == "User").unwrap();
let security_config = schema.security.as_ref().expect("security config present").clone();
let viewer_context = create_context("viewer");
let result1 = filter_fields(&viewer_context, &security_config, &user_type.fields);
let result2 = filter_fields(&viewer_context, &security_config, &user_type.fields);
let result3 = filter_fields(&viewer_context, &security_config, &user_type.fields);
assert_eq!(result1.len(), result2.len());
assert_eq!(result2.len(), result3.len());
let names1: Vec<&str> = result1.iter().map(|f| f.name.as_str()).collect();
let names2: Vec<&str> = result2.iter().map(|f| f.name.as_str()).collect();
let names3: Vec<&str> = result3.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names1, names2);
assert_eq!(names2, names3);
}
#[test]
fn test_field_filtering_mixed_nullability() {
let schema = create_schema_with_mixed_fields();
let user_type = schema.types.iter().find(|t| t.name == "User").unwrap();
let security_config = schema.security.as_ref().expect("security config present").clone();
let viewer_context = create_context("viewer");
let accessible = filter_fields(&viewer_context, &security_config, &user_type.fields);
let mut has_nullable = false;
let mut has_non_nullable = false;
for field in &accessible {
if field.nullable {
has_nullable = true;
} else {
has_non_nullable = true;
}
}
assert!(
has_nullable && has_non_nullable,
"Should have both nullable and non-nullable fields"
);
}
#[test]
fn test_field_filtering_empty_security_config() {
let schema = create_schema_with_mixed_fields();
let user_type = schema.types.iter().find(|t| t.name == "User").unwrap();
let empty_config = SecurityConfig::new();
let viewer_context = create_context("viewer");
let accessible = filter_fields(&viewer_context, &empty_config, &user_type.fields);
let names: Vec<&str> = accessible.iter().map(|f| f.name.as_str()).collect();
assert!(names.contains(&"id"));
assert!(names.contains(&"publicInfo"));
assert!(!names.contains(&"email"));
}
#[test]
fn test_field_filtering_preserves_metadata_on_filtered() {
let schema = create_schema_with_mixed_fields();
let user_type = schema.types.iter().find(|t| t.name == "User").unwrap();
let security_config = schema.security.as_ref().expect("security config present").clone();
let restricted_context = create_context("restricted");
let accessible = filter_fields(&restricted_context, &security_config, &user_type.fields);
for field in &accessible {
assert!(!field.name.is_empty(), "Field name must be present");
}
}