#![allow(clippy::unwrap_used, clippy::panic)] use std::collections::HashMap;
use chrono::Utc;
use indexmap::IndexMap;
use super::{test_support::*, *};
use crate::{
db::types::JsonbValue,
runtime::{JsonbOptimizationOptions, JsonbStrategy, RuntimeConfig},
schema::{
AutoParams, CompiledSchema, CursorType, FieldDefinition, FieldDenyPolicy, FieldType,
InjectedParamSource, QueryDefinition, RoleDefinition, SecurityConfig, TenancyConfig,
TypeDefinition,
},
security::SecurityContext,
};
mod query {
use super::*;
#[tokio::test]
async fn test_executor_new() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
assert_eq!(executor.schema().queries.len(), 1);
}
#[tokio::test]
async fn test_execute_query() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(mock_user_results()));
let executor = Executor::new(schema, adapter);
let query = "{ users { id name } }";
let result = executor.execute(query, None).await.unwrap();
assert!(result.get("data").is_some());
assert!(result["data"].get("users").is_some());
assert!(result["data"]["users"][0].get("id").is_some());
assert!(result["data"]["users"][0].get("name").is_some());
}
#[tokio::test]
async fn test_execute_json() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(mock_user_results()));
let executor = Executor::new(schema, adapter);
let query = "{ users { id name } }";
let result = executor.execute(query, None).await.unwrap();
assert!(result.get("data").is_some());
assert!(result["data"].get("users").is_some());
}
#[tokio::test]
async fn test_executor_with_config() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(vec![]));
let config = RuntimeConfig {
cache_query_plans: false,
max_query_depth: 5,
max_query_complexity: 500,
max_page_size: Some(1000),
enable_tracing: true,
field_filter: None,
rls_policy: None,
field_authorizer: None,
authorizer: None,
query_timeout_ms: 30_000,
jsonb_optimization: JsonbOptimizationOptions::default(),
query_validation: None,
audit_mutations: false,
changelog_enabled: true,
dry_run_mutations: false,
cascade_limits: crate::runtime::CascadeLimits::default(),
};
let executor = Executor::with_config(schema, adapter, config);
assert!(!executor.config().cache_query_plans);
assert_eq!(executor.config().max_query_depth, 5);
assert!(executor.config().enable_tracing);
}
}
mod introspection {
use super::*;
#[tokio::test]
async fn test_introspection_schema_query() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let query = r"{ __schema { queryType { name } } }";
let result = executor.execute(query, None).await.unwrap();
assert!(result["data"].get("__schema").is_some());
}
#[tokio::test]
async fn test_introspection_type_query() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let query = r#"{ __type(name: "Int") { kind name } }"#;
let result = executor.execute(query, None).await.unwrap();
assert!(result["data"].get("__type").is_some());
assert_eq!(result["data"]["__type"]["name"], "Int");
}
#[tokio::test]
async fn test_introspection_unknown_type() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let query = r#"{ __type(name: "UnknownType") { kind name } }"#;
let result = executor.execute(query, None).await.unwrap();
assert!(result["data"]["__type"].is_null());
}
}
mod typename {
use super::*;
#[tokio::test]
async fn test_root_typename_resolves_to_query() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let result = executor.execute("{ __typename }", None).await.unwrap();
assert_eq!(result, serde_json::json!({ "data": { "__typename": "Query" } }));
}
#[tokio::test]
async fn test_root_typename_aliased() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let result = executor.execute("{ ping: __typename }", None).await.unwrap();
assert_eq!(result, serde_json::json!({ "data": { "ping": "Query" } }));
}
#[tokio::test]
async fn test_root_typename_mixed_with_regular_field() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(mock_user_results()));
let executor = Executor::new(schema, adapter);
let result = executor.execute("{ __typename users { id } }", None).await.unwrap();
assert_eq!(result["data"]["__typename"], "Query");
assert!(result["data"]["users"].is_array());
assert!(result["data"]["users"][0].get("id").is_some());
}
#[tokio::test]
async fn test_root_typename_mixed_trailing() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(mock_user_results()));
let executor = Executor::new(schema, adapter);
let result = executor.execute("{ users { id } __typename }", None).await.unwrap();
assert_eq!(result["data"]["__typename"], "Query");
assert!(result["data"]["users"].is_array());
}
#[tokio::test]
async fn test_root_typename_multiple_aliased() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let result = executor.execute("{ a: __typename b: __typename }", None).await.unwrap();
assert_eq!(result, serde_json::json!({ "data": { "a": "Query", "b": "Query" } }));
}
#[tokio::test]
async fn test_root_typename_skipped_directive() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let result = executor.execute("{ __typename @skip(if: true) }", None).await.unwrap();
assert_eq!(result, serde_json::json!({ "data": {} }));
}
#[tokio::test]
async fn test_root_typename_excluded_directive() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let result = executor.execute("{ __typename @include(if: false) }", None).await.unwrap();
assert_eq!(result, serde_json::json!({ "data": {} }));
}
#[tokio::test]
async fn test_root_typename_kept_directive() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let result = executor.execute("{ __typename @skip(if: false) }", None).await.unwrap();
assert_eq!(result, serde_json::json!({ "data": { "__typename": "Query" } }));
}
#[tokio::test]
async fn test_root_typename_skip_via_variable() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let vars = serde_json::json!({ "drop": true });
let result = executor
.execute("query($drop: Boolean!) { __typename @skip(if: $drop) }", Some(&vars))
.await
.unwrap();
assert_eq!(result, serde_json::json!({ "data": {} }));
}
}
mod classify {
use super::*;
#[test]
fn test_detect_introspection_schema() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let query = r"{ __schema { types { name } } }";
assert_eq!(executor.classify_query(query).unwrap(), QueryType::IntrospectionSchema);
}
#[test]
fn test_detect_introspection_type() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let query = r#"{ __type(name: "User") { fields { name } } }"#;
assert_eq!(
executor.classify_query(query).unwrap(),
QueryType::IntrospectionType("User".to_string()),
);
}
#[test]
fn test_detect_root_typename() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let query = r"{ __typename }";
match executor.classify_query(query).unwrap() {
QueryType::TypeName {
selection,
operation_type,
} => {
assert_eq!(selection.response_key(), "__typename");
assert_eq!(operation_type, "query");
},
other => panic!("expected TypeName, got {other:?}"),
}
}
#[test]
fn test_detect_root_typename_aliased() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let query = r"{ ping: __typename }";
match executor.classify_query(query).unwrap() {
QueryType::TypeName {
selection,
operation_type,
} => {
assert_eq!(selection.response_key(), "ping");
assert_eq!(operation_type, "query");
},
other => panic!("expected TypeName, got {other:?}"),
}
}
#[test]
fn test_detect_root_typename_on_mutation() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let query = r"mutation { __typename }";
match executor.classify_query(query).unwrap() {
QueryType::TypeName {
selection,
operation_type,
} => {
assert_eq!(selection.response_key(), "__typename");
assert_eq!(operation_type, "mutation");
},
other => panic!("expected TypeName, got {other:?}"),
}
}
#[test]
fn test_classify_typename_prefix_is_regular() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let query = r"{ __typenameExtra }";
assert_eq!(executor.classify_query(query).unwrap(), QueryType::Regular);
}
#[test]
fn test_classify_node_query_inline_id() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let query = r#"{ node(id: "VXNlcjoxMjM=") { ... on User { name } } }"#;
assert!(matches!(executor.classify_query(query).unwrap(), QueryType::NodeQuery { .. }));
}
#[test]
fn test_classify_node_query_with_variable() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let query = r"query GetNode($id: ID!) { node(id: $id) { id } }";
assert!(matches!(executor.classify_query(query).unwrap(), QueryType::NodeQuery { .. }));
}
#[test]
fn test_classify_node_query_extracts_inline_fragment_selections() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let query = r#"{ node(id: "VXNlcjoxMjM=") { ... on User { name email } } }"#;
let qt = executor.classify_query(query).unwrap();
match qt {
QueryType::NodeQuery { selections } => {
let names: Vec<&str> = selections.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, vec!["name", "email"]);
},
other => panic!("expected NodeQuery, got {other:?}"),
}
}
#[test]
fn test_classify_node_query_direct_fields_without_fragment() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let query = r"query GetNode($id: ID!) { node(id: $id) { id name } }";
let qt = executor.classify_query(query).unwrap();
match qt {
QueryType::NodeQuery { selections } => {
let names: Vec<&str> = selections.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, vec!["id", "name"]);
},
other => panic!("expected NodeQuery, got {other:?}"),
}
}
#[test]
fn test_classify_node_query_rejects_substring_match() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let query = r#"{ nodeCounts(id: "x") { total } }"#;
assert_eq!(executor.classify_query(query).unwrap(), QueryType::Regular);
}
#[test]
fn test_classify_introspection_type_extracts_name() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let q = r#"{ __type(name: "User") { name } }"#;
assert_eq!(
executor.classify_query(q).unwrap(),
QueryType::IntrospectionType("User".to_string()),
);
let q2 = r#"{ __type(name:"Query") { name } }"#;
assert_eq!(
executor.classify_query(q2).unwrap(),
QueryType::IntrospectionType("Query".to_string()),
);
}
#[test]
fn test_classify_no_false_positive_schema_in_comment() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let q = "{ users { id } } # compare against __schema response";
assert_eq!(executor.classify_query(q).unwrap(), QueryType::Regular);
}
#[test]
fn test_classify_no_false_positive_service_in_argument() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let q = r#"{ search(query: "_service_name") { id } }"#;
assert_eq!(executor.classify_query(q).unwrap(), QueryType::Regular);
}
#[test]
fn test_classify_no_false_positive_entities_alias() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let q = r"{ _entities: users { id } }";
assert_eq!(executor.classify_query(q).unwrap(), QueryType::Regular);
}
#[test]
fn test_classify_federation_service() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let q = r"{ _service { sdl } }";
assert_eq!(
executor.classify_query(q).unwrap(),
QueryType::Federation("_service".to_string()),
);
}
#[test]
fn test_classify_federation_entities() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let q = r#"{ _entities(representations: [{ __typename: "User", id: "1" }]) { ... on User { name } } }"#;
assert_eq!(
executor.classify_query(q).unwrap(),
QueryType::Federation("_entities".to_string()),
);
}
}
#[cfg(feature = "federation")]
mod entities_authz {
use super::*;
use crate::{
schema::{FederationConfig, FederationEntity},
security::DefaultRLSPolicy,
};
fn entities_user_schema(
requires_role: Option<&str>,
inject_params: IndexMap<String, InjectedParamSource>,
) -> CompiledSchema {
let mut schema = CompiledSchema::new();
schema.federation = Some(FederationConfig {
enabled: true,
version: Some("v2".to_string()),
entities: vec![FederationEntity {
name: "User".to_string(),
key_fields: vec!["id".to_string()],
..Default::default()
}],
..Default::default()
});
schema.queries.push(QueryDefinition {
name: "users".to_string(),
return_type: "User".to_string(),
returns_list: true,
nullable: false,
arguments: Vec::new(),
sql_source: Some("v_user".to_string()),
description: None,
auto_params: AutoParams::default(),
deprecation: None,
jsonb_column: "data".to_string(),
relay: false,
relay_cursor_column: None,
relay_cursor_type: CursorType::default(),
inject_params,
cache_ttl_seconds: None,
additional_views: vec![],
requires_role: requires_role.map(str::to_string),
rest_path: None,
rest_method: None,
native_columns: HashMap::new(),
});
schema.types.push({
let mut t = TypeDefinition::new("User", "v_user");
t.fields = vec![
FieldDefinition::new("id", FieldType::String),
FieldDefinition::new("name", FieldType::String),
];
t
});
schema
}
fn entities_query() -> &'static str {
r#"{ _entities(representations: [{ __typename: "User", id: "1" }]) { ... on User { id name } } }"#
}
fn representations() -> serde_json::Value {
serde_json::json!({
"representations": [
{ "__typename": "User", "id": "11111111-1111-1111-1111-111111111111" }
]
})
}
fn ctx_with_roles(roles: &[&str]) -> SecurityContext {
SecurityContext {
user_id: "user-1".into(),
roles: roles.iter().map(|r| (*r).to_string()).collect(),
tenant_id: Some("tenant-abc".into()),
scopes: vec![],
attributes: HashMap::default(),
request_id: "req-1".to_string(),
ip_address: None,
expires_at: Utc::now() + chrono::Duration::hours(1),
authenticated_at: Utc::now(),
issuer: None,
audience: None,
email: None,
display_name: None,
}
}
fn is_authz(err: &FraiseQLError) -> bool {
matches!(err, FraiseQLError::Authorization { .. })
}
#[tokio::test]
async fn entities_rls_anonymous_fails_closed() {
let schema = entities_user_schema(None, IndexMap::new());
let adapter = Arc::new(CapturingMockAdapter::new(mock_user_results()));
let config = RuntimeConfig::default().with_rls_policy(Arc::new(DefaultRLSPolicy::new()));
let executor = Executor::with_config(schema, adapter.clone(), config);
let vars = representations();
let err = executor.execute(entities_query(), Some(&vars)).await.unwrap_err();
assert!(is_authz(&err), "anonymous _entities under RLS must be denied, got: {err}");
assert!(
adapter.captured_aggregate_sql().is_none(),
"resolver must run no SQL when denied (fail closed)"
);
}
#[tokio::test]
async fn entities_requires_role_authenticated_without_role_denied() {
let schema = entities_user_schema(Some("admin"), IndexMap::new());
let adapter = Arc::new(CapturingMockAdapter::new(mock_user_results()));
let executor = Executor::new(schema, adapter.clone());
let ctx = ctx_with_roles(&["viewer"]);
let vars = representations();
let err = executor
.execute_with_security(entities_query(), Some(&vars), &ctx)
.await
.unwrap_err();
assert!(is_authz(&err));
assert!(adapter.captured_aggregate_sql().is_none());
}
#[tokio::test]
async fn entities_requires_role_anonymous_denied() {
let schema = entities_user_schema(Some("admin"), IndexMap::new());
let adapter = Arc::new(CapturingMockAdapter::new(mock_user_results()));
let executor = Executor::new(schema, adapter.clone());
let vars = representations();
let err = executor.execute(entities_query(), Some(&vars)).await.unwrap_err();
assert!(is_authz(&err));
assert!(adapter.captured_aggregate_sql().is_none());
}
#[tokio::test]
async fn entities_inject_anonymous_fails_closed() {
let mut inject = IndexMap::new();
inject.insert("tenant_id".to_string(), InjectedParamSource::Jwt("tenant_id".to_string()));
let schema = entities_user_schema(None, inject);
let adapter = Arc::new(CapturingMockAdapter::new(mock_user_results()));
let executor = Executor::new(schema, adapter.clone());
let vars = representations();
let err = executor.execute(entities_query(), Some(&vars)).await.unwrap_err();
assert!(is_authz(&err));
assert!(adapter.captured_aggregate_sql().is_none());
}
#[tokio::test]
async fn entities_ungated_anonymous_resolves() {
let schema = entities_user_schema(None, IndexMap::new());
let adapter = Arc::new(CapturingMockAdapter::new(mock_user_results()));
let executor = Executor::new(schema, adapter.clone());
let vars = representations();
let result = executor.execute(entities_query(), Some(&vars)).await.unwrap();
assert!(result["data"].get("_entities").is_some());
assert!(
adapter.captured_aggregate_sql().is_some(),
"ungated entity resolution must reach the resolver"
);
}
#[tokio::test]
async fn entities_rls_authenticated_resolves_trusted_gateway() {
let schema = entities_user_schema(None, IndexMap::new());
let adapter = Arc::new(CapturingMockAdapter::new(mock_user_results()));
let config = RuntimeConfig::default().with_rls_policy(Arc::new(DefaultRLSPolicy::new()));
let executor = Executor::with_config(schema, adapter.clone(), config);
let ctx = ctx_with_roles(&["viewer"]);
let vars = representations();
let result = executor
.execute_with_security(entities_query(), Some(&vars), &ctx)
.await
.unwrap();
assert!(result["data"].get("_entities").is_some());
assert!(adapter.captured_aggregate_sql().is_some());
}
#[tokio::test]
async fn entities_inject_authenticated_composes_tenant_filter() {
let mut inject = IndexMap::new();
inject.insert("tenant_id".to_string(), InjectedParamSource::Jwt("tenant_id".to_string()));
let schema = entities_user_schema(None, inject);
let adapter = Arc::new(CapturingMockAdapter::new(mock_user_results()));
let executor = Executor::new(schema, adapter.clone());
let ctx = ctx_with_roles(&["viewer"]); let vars = representations();
let result = executor
.execute_with_security(entities_query(), Some(&vars), &ctx)
.await
.unwrap();
assert!(result["data"].get("_entities").is_some());
let sql = adapter.captured_aggregate_sql().expect("resolver must run");
assert!(
sql.contains("tenant_id"),
"resolver SQL must compose the per-row tenant inject predicate, got: {sql}"
);
assert!(
sql.contains(" AND ") && sql.contains("$2"),
"tenant predicate must be ANDed with a post-IN-clause placeholder, got: {sql}"
);
let params = adapter.captured_aggregate_params().unwrap_or_default();
assert_eq!(
params.get(1),
Some(&serde_json::json!("tenant-abc")),
"the caller's tenant id must bind to $2 (offset past the IN clause), got: {params:?}"
);
}
}
mod context {
use super::*;
#[test]
fn test_execution_context_creation() {
let ctx = ExecutionContext::new("query-123".to_string());
assert_eq!(ctx.query_id(), "query-123");
assert!(!ctx.is_cancelled());
}
#[test]
fn test_execution_context_cancellation_token() {
let ctx = ExecutionContext::new("query-456".to_string());
let token = ctx.cancellation_token();
assert!(!token.is_cancelled());
token.cancel();
assert!(token.is_cancelled());
assert!(ctx.is_cancelled());
}
#[tokio::test]
async fn test_execute_with_context_success() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let ctx = ExecutionContext::new("test-query-1".to_string());
let query = r"{ __schema { queryType { name } } }";
let result = executor.execute_with_context(query, None, &ctx).await;
let output = result.unwrap_or_else(|e| panic!("expected Ok for execute_with_context: {e}"));
assert!(output["data"].get("__schema").is_some());
}
#[tokio::test]
async fn test_execute_with_context_already_cancelled() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let ctx = ExecutionContext::new("test-query-2".to_string());
let token = ctx.cancellation_token().clone();
token.cancel();
let query = r"{ __schema { queryType { name } } }";
let result = executor.execute_with_context(query, None, &ctx).await;
let err = result.expect_err("expected Err for already-cancelled context");
match err {
FraiseQLError::Cancelled { query_id, reason } => {
assert_eq!(query_id, "test-query-2");
assert!(reason.contains("before execution"));
},
e => panic!("Expected Cancelled error, got: {}", e),
}
}
#[tokio::test(start_paused = true)]
async fn test_execute_with_context_cancelled_during_execution() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let ctx = ExecutionContext::new("test-query-3".to_string());
let token = ctx.cancellation_token().clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(10)).await;
token.cancel();
});
let query = r"{ __schema { queryType { name } } }";
let result = executor.execute_with_context(query, None, &ctx).await;
if let Err(FraiseQLError::Cancelled { query_id, .. }) = result {
assert_eq!(query_id, "test-query-3");
}
}
#[test]
fn test_execution_context_clone() {
let ctx = ExecutionContext::new("query-clone".to_string());
let ctx_clone = ctx.clone();
assert_eq!(ctx.query_id(), ctx_clone.query_id());
assert!(!ctx_clone.is_cancelled());
ctx.cancellation_token().cancel();
assert!(ctx_clone.is_cancelled());
}
#[test]
fn test_error_cancelled_constructor() {
let err = FraiseQLError::cancelled("query-001", "user requested cancellation");
assert!(err.to_string().contains("Query cancelled"));
assert_eq!(err.status_code(), 408);
assert_eq!(err.error_code(), "CANCELLED");
assert!(err.is_retryable());
assert!(err.is_client_error());
}
}
mod config {
use super::*;
#[test]
fn test_jsonb_strategy_in_runtime_config() {
let config = RuntimeConfig {
cache_query_plans: false,
max_query_depth: 5,
max_query_complexity: 500,
max_page_size: Some(1000),
enable_tracing: true,
field_filter: None,
rls_policy: None,
field_authorizer: None,
authorizer: None,
query_timeout_ms: 30_000,
jsonb_optimization: JsonbOptimizationOptions::default(),
query_validation: None,
audit_mutations: false,
changelog_enabled: true,
dry_run_mutations: false,
cascade_limits: crate::runtime::CascadeLimits::default(),
};
assert_eq!(config.jsonb_optimization.default_strategy, JsonbStrategy::Project);
assert_eq!(config.jsonb_optimization.auto_threshold_percent, 80);
}
#[test]
fn test_jsonb_strategy_custom_config() {
let custom_options = JsonbOptimizationOptions {
default_strategy: JsonbStrategy::Stream,
auto_threshold_percent: 50,
};
let config = RuntimeConfig {
cache_query_plans: false,
max_query_depth: 5,
max_query_complexity: 500,
max_page_size: Some(1000),
enable_tracing: true,
field_filter: None,
rls_policy: None,
field_authorizer: None,
authorizer: None,
query_timeout_ms: 30_000,
jsonb_optimization: custom_options,
query_validation: None,
audit_mutations: false,
changelog_enabled: true,
dry_run_mutations: false,
cascade_limits: crate::runtime::CascadeLimits::default(),
};
assert_eq!(config.jsonb_optimization.default_strategy, JsonbStrategy::Stream);
assert_eq!(config.jsonb_optimization.auto_threshold_percent, 50);
}
}
mod inject {
use super::*;
fn make_security_ctx(
user_id: &str,
tenant_id: Option<&str>,
extra: &[(&str, serde_json::Value)],
) -> SecurityContext {
use chrono::Utc;
let now = Utc::now();
SecurityContext {
user_id: crate::types::UserId::new(user_id),
roles: vec![],
tenant_id: tenant_id.map(crate::types::TenantId::new),
scopes: vec![],
attributes: extra.iter().map(|(k, v)| ((*k).to_string(), v.clone())).collect(),
request_id: "test-req".to_string(),
ip_address: None,
authenticated_at: now,
expires_at: now + chrono::Duration::hours(1),
issuer: None,
audience: None,
email: None,
display_name: None,
}
}
#[test]
fn test_resolve_inject_sub_maps_to_user_id() {
let ctx = make_security_ctx("user-42", None, &[]);
let source = InjectedParamSource::Jwt("sub".to_string());
let result = resolve_inject_value("user_id", &source, &ctx).unwrap();
assert_eq!(result, serde_json::Value::String("user-42".to_string()));
}
#[test]
fn test_resolve_inject_tenant_id_claim() {
let ctx = make_security_ctx("user-1", Some("tenant-abc"), &[]);
let source = InjectedParamSource::Jwt("tenant_id".to_string());
let result = resolve_inject_value("tenant_id", &source, &ctx).unwrap();
assert_eq!(result, serde_json::Value::String("tenant-abc".to_string()));
}
#[test]
fn test_resolve_inject_org_id_alias() {
let ctx = make_security_ctx("user-1", Some("org-xyz"), &[]);
let source = InjectedParamSource::Jwt("org_id".to_string());
let result = resolve_inject_value("org_id", &source, &ctx).unwrap();
assert_eq!(result, serde_json::Value::String("org-xyz".to_string()));
}
#[test]
fn test_resolve_inject_custom_attribute() {
let ctx =
make_security_ctx("user-1", None, &[("department", serde_json::json!("engineering"))]);
let source = InjectedParamSource::Jwt("department".to_string());
let result = resolve_inject_value("dept", &source, &ctx).unwrap();
assert_eq!(result, serde_json::Value::String("engineering".to_string()));
}
#[test]
fn test_resolve_inject_missing_claim_returns_error() {
let ctx = make_security_ctx("user-1", None, &[]);
let source = InjectedParamSource::Jwt("org_id".to_string());
let err = resolve_inject_value("org_id", &source, &ctx).unwrap_err();
assert!(matches!(err, FraiseQLError::Validation { .. }));
let msg = err.to_string();
assert!(msg.contains("org_id"), "Error should mention claim name");
}
#[test]
fn test_resolve_inject_enrichment_reads_namespace() {
let ctx = make_security_ctx(
"user-1",
None,
&[("fraiseql.enriched.actor_id", serde_json::json!("a-1"))],
);
let source = InjectedParamSource::Enrichment("actor_id".to_string());
let result = resolve_inject_value("actor_id", &source, &ctx).unwrap();
assert_eq!(result, serde_json::Value::String("a-1".to_string()));
}
#[test]
fn test_resolve_inject_enrichment_missing_field_errors() {
let ctx = make_security_ctx("user-1", None, &[]);
let source = InjectedParamSource::Enrichment("actor_id".to_string());
assert!(resolve_inject_value("actor_id", &source, &ctx).is_err());
}
#[test]
fn test_resolve_inject_enrichment_does_not_fall_back_to_raw_claim() {
let ctx = make_security_ctx("user-1", None, &[("actor_id", serde_json::json!("forged"))]);
let source = InjectedParamSource::Enrichment("actor_id".to_string());
assert!(
resolve_inject_value("actor_id", &source, &ctx).is_err(),
"Enrichment inject param must not fall back to a raw claim"
);
}
#[test]
fn test_resolve_inject_missing_tenant_id_returns_error() {
let ctx = make_security_ctx("user-1", None, &[]);
let source = InjectedParamSource::Jwt("tenant_id".to_string());
let err = resolve_inject_value("tenant_id", &source, &ctx).unwrap_err();
assert!(matches!(err, FraiseQLError::Validation { .. }));
}
#[tokio::test]
async fn test_query_with_inject_rejects_unauthenticated() {
let mut schema = test_schema();
let mut inject_params = IndexMap::new();
inject_params.insert("org_id".to_string(), InjectedParamSource::Jwt("org_id".to_string()));
schema.queries.push(QueryDefinition {
name: "org_items".to_string(),
return_type: "User".to_string(),
returns_list: true,
nullable: false,
arguments: Vec::new(),
sql_source: Some("v_org_items".to_string()),
description: None,
auto_params: AutoParams::default(),
deprecation: None,
jsonb_column: "data".to_string(),
relay: false,
relay_cursor_column: None,
relay_cursor_type: CursorType::default(),
inject_params,
cache_ttl_seconds: None,
additional_views: vec![],
requires_role: None,
rest_path: None,
rest_method: None,
native_columns: HashMap::new(),
});
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let result = executor.execute("{ org_items { id } }", None).await;
let err = result.expect_err("Expected Err for unauthenticated inject query");
assert!(
matches!(err, FraiseQLError::Validation { .. }),
"Expected Validation error, got: {err:?}"
);
}
}
mod masking {
use super::*;
#[test]
fn test_null_masked_fields_object() {
let mut value = serde_json::json!({"id": 1, "email": "alice@example.com", "name": "Alice"});
null_masked_fields(&mut value, &["email".to_string()]);
assert_eq!(value, serde_json::json!({"id": 1, "email": null, "name": "Alice"}));
}
#[test]
fn test_null_masked_fields_array() {
let mut value = serde_json::json!([
{"id": 1, "email": "a@b.com", "salary": 100_000},
{"id": 2, "email": "c@d.com", "salary": 120_000},
]);
null_masked_fields(&mut value, &["email".to_string(), "salary".to_string()]);
assert_eq!(
value,
serde_json::json!([
{"id": 1, "email": null, "salary": null},
{"id": 2, "email": null, "salary": null},
])
);
}
#[test]
fn test_null_masked_fields_no_masked() {
let mut value = serde_json::json!({"id": 1, "name": "Alice"});
let original = value.clone();
null_masked_fields(&mut value, &[]);
assert_eq!(value, original);
}
}
mod planning {
use super::*;
#[test]
fn test_plan_query_regular() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let plan = executor.plan_query("{ users { id name } }", None).unwrap();
assert_eq!(plan.query_type, "regular");
assert!(plan.sql.contains("v_user"));
assert_eq!(plan.views_accessed, vec!["v_user"]);
assert!(plan.estimated_cost > 0);
}
#[test]
fn test_plan_query_introspection() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let plan = executor.plan_query("{ __schema { types { name } } }", None).unwrap();
assert_eq!(plan.query_type, "introspection");
assert!(plan.sql.is_empty());
assert!(plan.views_accessed.is_empty());
}
#[test]
fn test_plan_query_empty_rejected() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let result = executor.plan_query("", None);
assert!(result.is_err(), "expected Err for empty query, got: {result:?}");
}
}
mod security {
#[test]
fn test_alias_limit_enforced_when_depth_and_complexity_disabled() {
use crate::graphql::complexity::{ComplexityValidationError, RequestValidator};
let validator = RequestValidator::new()
.with_depth_validation(false)
.with_complexity_validation(false)
.with_max_aliases(2);
let query = "query { a: users { id } b: users { id } c: users { id } }";
let result = validator.validate_query(query);
let err = result
.expect_err("alias limit must be enforced even when depth and complexity are disabled");
assert!(
matches!(
err,
ComplexityValidationError::TooManyAliases {
actual_aliases: 3,
..
}
),
"error must be TooManyAliases with actual_aliases = 3, got: {err:?}"
);
}
#[test]
fn test_alias_within_limit_passes_when_depth_and_complexity_disabled() {
use crate::graphql::complexity::RequestValidator;
let validator = RequestValidator::new()
.with_depth_validation(false)
.with_complexity_validation(false)
.with_max_aliases(5);
let query = "query { a: users { id } b: users { id } }";
validator.validate_query(query).unwrap_or_else(|e| {
panic!(
"query within alias limit must pass when depth and complexity are disabled: {e:?}"
)
});
}
}
mod field_rbac {
use super::*;
fn schema_with_rbac_fields() -> CompiledSchema {
let mut schema = CompiledSchema::new();
schema.queries.push(QueryDefinition {
name: "users".to_string(),
return_type: "User".to_string(),
returns_list: true,
nullable: false,
arguments: Vec::new(),
sql_source: Some("v_user".to_string()),
description: None,
auto_params: AutoParams::default(),
deprecation: None,
jsonb_column: "data".to_string(),
relay: false,
relay_cursor_column: None,
relay_cursor_type: CursorType::default(),
inject_params: IndexMap::default(),
cache_ttl_seconds: None,
additional_views: vec![],
requires_role: None,
rest_path: None,
rest_method: None,
native_columns: HashMap::new(),
});
let mut user_type = TypeDefinition::new("User", "v_user");
user_type.fields = vec![
FieldDefinition {
name: "id".into(),
field_type: FieldType::Int,
nullable: false,
description: None,
default_value: None,
vector_config: None,
alias: None,
deprecation: None,
requires_scope: None,
on_deny: FieldDenyPolicy::Reject,
authorize: false,
encryption: None,
hierarchy: None,
},
FieldDefinition {
name: "name".into(),
field_type: FieldType::String,
nullable: false,
description: None,
default_value: None,
vector_config: None,
alias: None,
deprecation: None,
requires_scope: None,
on_deny: FieldDenyPolicy::Reject,
authorize: false,
encryption: None,
hierarchy: None,
},
FieldDefinition {
name: "salary".into(),
field_type: FieldType::Int,
nullable: true,
description: None,
default_value: None,
vector_config: None,
alias: None,
deprecation: None,
requires_scope: Some("admin:*".to_string()),
on_deny: FieldDenyPolicy::Reject,
authorize: false,
encryption: None,
hierarchy: None,
},
FieldDefinition {
name: "email".into(),
field_type: FieldType::String,
nullable: true,
description: None,
default_value: None,
vector_config: None,
alias: None,
deprecation: None,
requires_scope: Some("read:User.email".to_string()),
on_deny: FieldDenyPolicy::Mask,
authorize: false,
encryption: None,
hierarchy: None,
},
];
schema.security = Some(SecurityConfig {
role_definitions: vec![
RoleDefinition {
name: "viewer".into(),
description: None,
scopes: vec!["read:User".into()],
},
RoleDefinition {
name: "admin".into(),
description: None,
scopes: vec!["admin:*".into(), "read:User.email".into()],
},
],
default_role: None,
multi_tenant: false,
tenancy: TenancyConfig::default(),
additional: HashMap::default(),
});
schema.types.push(user_type);
schema
}
fn viewer_context() -> SecurityContext {
SecurityContext {
user_id: "user-42".into(),
roles: vec!["viewer".to_string()],
tenant_id: None,
scopes: vec!["read:User".to_string()],
attributes: HashMap::default(),
request_id: "req-001".to_string(),
ip_address: None,
expires_at: Utc::now() + chrono::Duration::hours(1),
authenticated_at: Utc::now(),
issuer: None,
audience: None,
email: None,
display_name: None,
}
}
fn admin_context() -> SecurityContext {
SecurityContext {
user_id: "admin-1".into(),
roles: vec!["admin".to_string()],
tenant_id: None,
scopes: vec!["admin:*".to_string(), "read:User.email".to_string()],
attributes: HashMap::default(),
request_id: "req-002".to_string(),
ip_address: None,
expires_at: Utc::now() + chrono::Duration::hours(1),
authenticated_at: Utc::now(),
issuer: None,
audience: None,
email: None,
display_name: None,
}
}
#[tokio::test]
async fn test_reject_field_returns_authorization_error() {
let schema = schema_with_rbac_fields();
let adapter = Arc::new(MockAdapter::new(mock_user_results()));
let config = RuntimeConfig::default();
let executor = Executor::with_config(schema, adapter, config);
let ctx = viewer_context();
let result = executor.execute_with_security("{ users { id salary } }", None, &ctx).await;
assert!(result.is_err(), "querying rejected field should fail");
let err = result.unwrap_err();
let err_msg = format!("{err}");
assert!(
err_msg.contains("salary")
|| err_msg.contains("authorization")
|| err_msg.contains("Authorization")
|| err_msg.contains("forbidden")
|| err_msg.contains("Forbidden"),
"error should mention the forbidden field or authorization, got: {err_msg}"
);
}
#[tokio::test]
async fn test_reject_field_allowed_for_admin() {
let schema = schema_with_rbac_fields();
let adapter = Arc::new(MockAdapter::new(mock_user_results()));
let config = RuntimeConfig::default();
let executor = Executor::with_config(schema, adapter, config);
let ctx = admin_context();
let result = executor.execute_with_security("{ users { id salary } }", None, &ctx).await;
assert!(
result.is_ok(),
"admin should be able to query rejected field: {:?}",
result.err()
);
}
#[tokio::test]
async fn test_mask_field_returns_null_for_unauthorized() {
let schema = schema_with_rbac_fields();
let results = vec![JsonbValue::new(
serde_json::json!({"id": 1, "name": "Alice", "email": "alice@example.com"}),
)];
let adapter = Arc::new(MockAdapter::new(results));
let config = RuntimeConfig::default();
let executor = Executor::with_config(schema, adapter, config);
let ctx = viewer_context();
let result = executor
.execute_with_security("{ users { id email } }", None, &ctx)
.await
.unwrap();
let users = &result["data"]["users"];
assert!(users.is_array(), "expected users array in response: {result}");
for user in users.as_array().unwrap() {
assert!(
user["email"].is_null(),
"masked field 'email' should be null for unauthorized user, got: {}",
user["email"]
);
assert!(!user["id"].is_null(), "unmasked field 'id' should have real value");
}
}
#[tokio::test]
async fn test_mask_field_returns_real_value_for_authorized() {
let schema = schema_with_rbac_fields();
let results = vec![JsonbValue::new(
serde_json::json!({"id": 1, "name": "Alice", "email": "alice@example.com"}),
)];
let adapter = Arc::new(MockAdapter::new(results));
let config = RuntimeConfig::default();
let executor = Executor::with_config(schema, adapter, config);
let ctx = admin_context();
let result = executor
.execute_with_security("{ users { id email } }", None, &ctx)
.await
.unwrap();
let users = &result["data"]["users"];
for user in users.as_array().unwrap() {
assert_eq!(
user["email"], "alice@example.com",
"authorized user should see real email value"
);
}
}
#[tokio::test]
async fn test_public_fields_always_accessible() {
let schema = schema_with_rbac_fields();
let adapter = Arc::new(MockAdapter::new(mock_user_results()));
let config = RuntimeConfig::default();
let executor = Executor::with_config(schema, adapter, config);
let ctx = viewer_context();
let result = executor.execute_with_security("{ users { id name } }", None, &ctx).await;
assert!(result.is_ok(), "public fields should always be accessible: {:?}", result.err());
}
}
mod executor_paths {
use super::*;
#[tokio::test]
async fn test_requires_role_returns_not_found_not_forbidden() {
let mut schema = test_schema();
schema.queries[0].requires_role = Some("admin".to_string());
let adapter = Arc::new(MockAdapter::new(mock_user_results()));
let executor = Executor::new(schema, adapter);
let result = executor.execute("{ users { id } }", None).await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("not found in schema"),
"requires_role should produce 'not found', not 'forbidden', got: {err}"
);
assert!(
!err.contains("forbidden") && !err.contains("Forbidden"),
"must not reveal the query exists behind a role gate, got: {err}"
);
}
#[tokio::test]
async fn test_requires_role_wrong_role_returns_not_found() {
let mut schema = test_schema();
schema.queries[0].requires_role = Some("admin".to_string());
let adapter = Arc::new(MockAdapter::new(mock_user_results()));
let executor = Executor::new(schema, adapter);
let ctx = SecurityContext {
user_id: "user-42".into(),
roles: vec!["viewer".to_string()],
tenant_id: None,
scopes: vec![],
attributes: HashMap::default(),
request_id: "req-001".to_string(),
ip_address: None,
expires_at: Utc::now() + chrono::Duration::hours(1),
authenticated_at: Utc::now(),
issuer: None,
audience: None,
email: None,
display_name: None,
};
let result = executor.execute_with_security("{ users { id } }", None, &ctx).await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("not found in schema"),
"wrong role should produce 'not found', got: {err}"
);
}
#[tokio::test]
async fn test_requires_role_correct_role_succeeds() {
let mut schema = test_schema();
schema.queries[0].requires_role = Some("admin".to_string());
let adapter = Arc::new(MockAdapter::new(mock_user_results()));
let executor = Executor::new(schema, adapter);
let ctx = SecurityContext {
user_id: "admin-1".into(),
roles: vec!["admin".to_string()],
tenant_id: None,
scopes: vec![],
attributes: HashMap::default(),
request_id: "req-002".to_string(),
ip_address: None,
expires_at: Utc::now() + chrono::Duration::hours(1),
authenticated_at: Utc::now(),
issuer: None,
audience: None,
email: None,
display_name: None,
};
let result = executor.execute_with_security("{ users { id } }", None, &ctx).await;
assert!(result.is_ok(), "correct role should succeed: {:?}", result.err());
}
}
mod parse_cache {
use super::*;
#[tokio::test]
async fn test_cache_empty_before_first_execute() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(mock_user_results()));
let executor = Executor::new(schema, adapter);
assert_eq!(executor.parse_cache_entry_count(), 0, "cache must be empty before any call");
}
#[tokio::test]
async fn test_cache_populated_after_first_execute() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(mock_user_results()));
let executor = Executor::new(schema, adapter);
executor.execute("{ users { id name } }", None).await.unwrap();
executor.ctx.parse_cache.run_pending_tasks();
assert_eq!(
executor.parse_cache_entry_count(),
1,
"one distinct query must produce exactly one cache entry"
);
}
#[tokio::test]
async fn test_cache_no_double_insert_for_repeated_query() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(mock_user_results()));
let executor = Executor::new(schema, adapter);
let query = "{ users { id name } }";
executor.execute(query, None).await.unwrap();
executor.execute(query, None).await.unwrap();
executor.execute(query, None).await.unwrap();
executor.ctx.parse_cache.run_pending_tasks();
assert_eq!(
executor.parse_cache_entry_count(),
1,
"repeating the same query must not grow the cache beyond 1 entry"
);
}
#[tokio::test]
async fn test_cache_separate_entries_for_distinct_queries() {
let schema = test_schema();
let adapter = Arc::new(MockAdapter::new(mock_user_results()));
let executor = Executor::new(schema, adapter);
executor.execute("{ users { id name } }", None).await.unwrap();
executor.execute("{ users { id } }", None).await.unwrap();
executor.ctx.parse_cache.run_pending_tasks();
assert_eq!(
executor.parse_cache_entry_count(),
2,
"two distinct query strings must produce two cache entries"
);
}
}
mod field_authz {
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use super::*;
use crate::{
cache::{ResponseCache, ResponseCacheConfig},
error::{FraiseQLError, Result as FqlResult},
security::{FieldAuthorizer, FieldAuthzDecision, FieldAuthzRequest},
};
struct AllowAll;
impl FieldAuthorizer for AllowAll {
fn authorize_field(&self, _req: &FieldAuthzRequest<'_>) -> FqlResult<FieldAuthzDecision> {
Ok(FieldAuthzDecision::Allow)
}
}
struct DenyReject;
impl FieldAuthorizer for DenyReject {
fn authorize_field(&self, _req: &FieldAuthzRequest<'_>) -> FqlResult<FieldAuthzDecision> {
Ok(FieldAuthzDecision::Deny {
code: "nope".into(),
on_deny: FieldDenyPolicy::Reject,
})
}
}
struct Raising;
impl FieldAuthorizer for Raising {
fn authorize_field(&self, _req: &FieldAuthzRequest<'_>) -> FqlResult<FieldAuthzDecision> {
Err(FraiseQLError::Validation {
message: "policy backend down".into(),
path: None,
})
}
}
struct OwnerOnly;
impl FieldAuthorizer for OwnerOnly {
fn authorize_field(&self, req: &FieldAuthzRequest<'_>) -> FqlResult<FieldAuthzDecision> {
let owner = req.parent.and_then(|p| p.get("owner_id")).and_then(|v| v.as_str());
if owner == Some(req.principal.user_id.as_str()) {
Ok(FieldAuthzDecision::Allow)
} else {
Ok(FieldAuthzDecision::Deny {
code: "not_owner".into(),
on_deny: FieldDenyPolicy::Mask,
})
}
}
}
struct PanicIfCalled;
impl FieldAuthorizer for PanicIfCalled {
fn authorize_field(&self, _req: &FieldAuthzRequest<'_>) -> FqlResult<FieldAuthzDecision> {
panic!("field authorizer must not be consulted here");
}
}
struct FlipAuthorizer {
calls: AtomicUsize,
}
impl FieldAuthorizer for FlipAuthorizer {
fn authorize_field(&self, _req: &FieldAuthzRequest<'_>) -> FqlResult<FieldAuthzDecision> {
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
Ok(FieldAuthzDecision::Allow)
} else {
Ok(FieldAuthzDecision::Deny {
code: "later".into(),
on_deny: FieldDenyPolicy::Mask,
})
}
}
}
fn users_query() -> QueryDefinition {
QueryDefinition {
name: "users".to_string(),
return_type: "User".to_string(),
returns_list: true,
nullable: false,
arguments: Vec::new(),
sql_source: Some("v_user".to_string()),
description: None,
auto_params: AutoParams::default(),
deprecation: None,
jsonb_column: "data".to_string(),
relay: false,
relay_cursor_column: None,
relay_cursor_type: CursorType::default(),
inject_params: IndexMap::default(),
cache_ttl_seconds: None,
additional_views: vec![],
requires_role: None,
rest_path: None,
rest_method: None,
native_columns: HashMap::new(),
}
}
fn gated_schema(email_scope: Option<(&str, FieldDenyPolicy)>) -> CompiledSchema {
let mut schema = CompiledSchema::new();
schema.queries.push(users_query());
let mut email = FieldDefinition::nullable("email", FieldType::String).with_authorize(true);
if let Some((scope, on_deny)) = email_scope {
email = email.with_requires_scope(scope).with_on_deny(on_deny);
}
let mut user_type = TypeDefinition::new("User", "v_user");
user_type.fields = vec![
FieldDefinition::new("id", FieldType::Int),
FieldDefinition::nullable("name", FieldType::String),
email,
FieldDefinition::nullable("owner_id", FieldType::String),
];
schema.security = Some(SecurityConfig {
role_definitions: vec![RoleDefinition {
name: "viewer".into(),
description: None,
scopes: vec!["read:User".into()],
}],
default_role: None,
multi_tenant: false,
tenancy: TenancyConfig::default(),
additional: HashMap::default(),
});
schema.types.push(user_type);
schema
}
fn ctx(user_id: &str) -> SecurityContext {
SecurityContext {
user_id: user_id.into(),
roles: vec!["viewer".to_string()],
tenant_id: None,
scopes: vec!["read:User".to_string()],
attributes: HashMap::default(),
request_id: "req-authz".to_string(),
ip_address: None,
expires_at: Utc::now() + chrono::Duration::hours(1),
authenticated_at: Utc::now(),
issuer: None,
audience: None,
email: None,
display_name: None,
}
}
fn rows() -> Vec<JsonbValue> {
vec![
JsonbValue::new(serde_json::json!({
"id": 1, "name": "Alice", "email": "alice@x.com", "owner_id": "user-1"
})),
JsonbValue::new(serde_json::json!({
"id": 2, "name": "Bob", "email": "bob@x.com", "owner_id": "user-2"
})),
]
}
#[tokio::test]
async fn raising_policy_denies_query() {
let executor = Executor::with_config(
gated_schema(None),
Arc::new(MockAdapter::new(rows())),
RuntimeConfig::default().with_field_authorizer(Arc::new(Raising)),
);
let result = executor
.execute_with_security("{ users { id email } }", None, &ctx("user-1"))
.await;
assert!(result.is_err(), "raising policy must fail closed");
let msg = format!("{}", result.unwrap_err());
assert!(!msg.contains("alice@x.com"), "must never leak the field value: {msg}");
}
#[tokio::test]
async fn deny_reject_returns_authorization() {
let executor = Executor::with_config(
gated_schema(None),
Arc::new(MockAdapter::new(rows())),
RuntimeConfig::default().with_field_authorizer(Arc::new(DenyReject)),
);
let result = executor
.execute_with_security("{ users { id email } }", None, &ctx("user-1"))
.await;
assert!(result.is_err());
let msg = format!("{}", result.unwrap_err());
assert!(msg.contains("email"), "error should name the denied field: {msg}");
}
#[tokio::test]
async fn gated_field_without_authorizer_fails_closed() {
let executor = Executor::with_config(
gated_schema(None),
Arc::new(MockAdapter::new(rows())),
RuntimeConfig::default(), );
let result = executor
.execute_with_security("{ users { id email } }", None, &ctx("user-1"))
.await;
assert!(result.is_err(), "gated field with no authorizer must fail closed");
}
#[tokio::test]
async fn owner_only_masks_non_owner_rows() {
let executor = Executor::with_config(
gated_schema(None),
Arc::new(MockAdapter::new(rows())),
RuntimeConfig::default().with_field_authorizer(Arc::new(OwnerOnly)),
);
let result = executor
.execute_with_security("{ users { id email } }", None, &ctx("user-1"))
.await
.unwrap();
let users = result["data"]["users"].as_array().unwrap();
assert_eq!(users[0]["email"], "alice@x.com", "owner row keeps the field");
assert!(users[1]["email"].is_null(), "non-owner row is masked: {}", users[1]);
}
#[tokio::test]
async fn no_gated_field_selected_skips_authorizer() {
let executor = Executor::with_config(
gated_schema(None),
Arc::new(MockAdapter::new(rows())),
RuntimeConfig::default().with_field_authorizer(Arc::new(PanicIfCalled)),
);
let result = executor
.execute_with_security("{ users { id name } }", None, &ctx("user-1"))
.await
.unwrap();
let users = result["data"]["users"].as_array().unwrap();
assert_eq!(users[0]["name"], "Alice");
assert!(users[0].get("email").is_none(), "email not selected → absent");
}
#[tokio::test]
async fn static_reject_precedes_dynamic_allow() {
let executor = Executor::with_config(
gated_schema(Some(("admin:*", FieldDenyPolicy::Reject))),
Arc::new(MockAdapter::new(rows())),
RuntimeConfig::default().with_field_authorizer(Arc::new(AllowAll)),
);
let result = executor
.execute_with_security("{ users { id email } }", None, &ctx("user-1"))
.await;
assert!(result.is_err(), "static reject must win over a dynamic Allow");
}
#[tokio::test]
async fn static_mask_skips_dynamic_authorizer() {
let executor = Executor::with_config(
gated_schema(Some(("read:User.email", FieldDenyPolicy::Mask))),
Arc::new(MockAdapter::new(rows())),
RuntimeConfig::default().with_field_authorizer(Arc::new(PanicIfCalled)),
);
let result = executor
.execute_with_security("{ users { id email } }", None, &ctx("user-1"))
.await
.unwrap();
let users = result["data"]["users"].as_array().unwrap();
assert!(users[0]["email"].is_null(), "statically-masked field stays null");
}
#[tokio::test]
async fn response_cache_bypassed_for_gated_query() {
let cache = Arc::new(ResponseCache::new(ResponseCacheConfig {
enabled: true,
max_entries: 100,
ttl_seconds: 3600,
}));
let executor = Executor::with_config(
gated_schema(None),
Arc::new(MockAdapter::new(rows())),
RuntimeConfig::default().with_field_authorizer(Arc::new(FlipAuthorizer {
calls: AtomicUsize::new(0),
})),
)
.with_response_cache(cache);
let q = "{ users { id email } }";
let first = executor.execute_with_security(q, None, &ctx("user-1")).await.unwrap();
let second = executor.execute_with_security(q, None, &ctx("user-1")).await.unwrap();
assert_eq!(first["data"]["users"][0]["email"], "alice@x.com");
assert!(
second["data"]["users"][0]["email"].is_null(),
"a stale cached Allow must not be replayed for a gated query"
);
}
#[tokio::test]
async fn anonymous_query_with_gated_field_fails_closed() {
let executor = Executor::with_config(
gated_schema(None),
Arc::new(MockAdapter::new(rows())),
RuntimeConfig::default().with_field_authorizer(Arc::new(AllowAll)),
);
let res = executor.execute("{ users { id email } }", None).await;
assert!(res.is_err(), "unauthenticated query selecting a gated field must fail closed");
}
#[tokio::test]
async fn node_query_with_gated_type_fails_closed() {
let executor = Executor::with_config(
gated_schema(None),
Arc::new(MockAdapter::new(rows())),
RuntimeConfig::default().with_field_authorizer(Arc::new(AllowAll)),
);
let res = executor
.execute(r#"{ node(id: "VXNlcjoxMjM=") { ... on User { email } } }"#, None)
.await;
assert!(res.is_err(), "node lookup of a gated type must fail closed");
}
#[test]
fn schema_gated_field_helpers() {
let gated = gated_schema(None);
assert!(gated.type_has_gated_field("User"));
assert!(!gated.type_has_gated_field("Unknown"));
assert!(gated.has_any_authorize_field());
assert!(!test_schema().has_any_authorize_field());
}
}
mod operation_authz {
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
use crate::{
cache::{ResponseCache, ResponseCacheConfig},
error::{FraiseQLError, Result as FqlResult},
schema::{MutationDefinition, MutationOperation},
security::{Authorizer, AuthzDecision, AuthzRequest},
};
struct AllowAll;
impl Authorizer for AllowAll {
fn authorize(&self, _req: &AuthzRequest<'_>) -> FqlResult<AuthzDecision> {
Ok(AuthzDecision::Allow)
}
}
struct DenyAll;
impl Authorizer for DenyAll {
fn authorize(&self, _req: &AuthzRequest<'_>) -> FqlResult<AuthzDecision> {
Ok(AuthzDecision::Deny {
reason: "nope".into(),
})
}
}
struct Raising;
impl Authorizer for Raising {
fn authorize(&self, _req: &AuthzRequest<'_>) -> FqlResult<AuthzDecision> {
Err(FraiseQLError::Validation {
message: "policy backend down".into(),
path: None,
})
}
}
struct DenyNamed(&'static str);
impl Authorizer for DenyNamed {
fn authorize(&self, req: &AuthzRequest<'_>) -> FqlResult<AuthzDecision> {
if req.name == self.0 {
Ok(AuthzDecision::Deny {
reason: "named-deny".into(),
})
} else {
Ok(AuthzDecision::Allow)
}
}
}
struct RequireAuthenticated;
impl Authorizer for RequireAuthenticated {
fn authorize(&self, req: &AuthzRequest<'_>) -> FqlResult<AuthzDecision> {
if req.principal.is_some() {
Ok(AuthzDecision::Allow)
} else {
Ok(AuthzDecision::Deny {
reason: "auth required".into(),
})
}
}
}
struct Flip {
calls: AtomicUsize,
}
impl Authorizer for Flip {
fn authorize(&self, _req: &AuthzRequest<'_>) -> FqlResult<AuthzDecision> {
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
Ok(AuthzDecision::Allow)
} else {
Ok(AuthzDecision::Deny {
reason: "later".into(),
})
}
}
}
struct PanicIfCalled;
impl Authorizer for PanicIfCalled {
fn authorize(&self, _req: &AuthzRequest<'_>) -> FqlResult<AuthzDecision> {
panic!("authorizer must not be consulted");
}
}
fn ctx(user_id: &str) -> SecurityContext {
SecurityContext {
user_id: user_id.into(),
roles: vec![],
tenant_id: None,
scopes: vec![],
attributes: HashMap::default(),
request_id: "req-op-authz".to_string(),
ip_address: None,
expires_at: Utc::now() + chrono::Duration::hours(1),
authenticated_at: Utc::now(),
issuer: None,
audience: None,
email: None,
display_name: None,
}
}
fn admin_ctx(user_id: &str) -> SecurityContext {
let mut c = ctx(user_id);
c.roles = vec!["admin".to_string()];
c
}
fn schema_requires_admin() -> CompiledSchema {
let mut schema = test_schema();
schema.queries[0].requires_role = Some("admin".to_string());
schema
}
fn schema_with_mutation() -> CompiledSchema {
let mut schema = CompiledSchema::new();
let mut def = MutationDefinition::new("createUser", "User");
def.sql_source = Some("fn_create_user".to_string());
def.operation = MutationOperation::Insert {
table: "users".to_string(),
};
schema.mutations.push(def);
schema
}
fn is_authz(err: &FraiseQLError) -> bool {
matches!(err, FraiseQLError::Authorization { .. })
}
#[tokio::test]
async fn authenticated_deny_returns_403() {
let executor = Executor::with_config(
test_schema(),
Arc::new(MockAdapter::new(mock_user_results())),
RuntimeConfig::default().with_authorizer(Arc::new(DenyAll)),
);
let err = executor
.execute_with_security("{ users { id name } }", None, &ctx("u1"))
.await
.unwrap_err();
assert!(is_authz(&err), "deny must map to Authorization/403: {err:?}");
assert!(!format!("{err}").contains("Alice"), "must not leak data");
}
#[tokio::test]
async fn authenticated_raise_fails_closed() {
let executor = Executor::with_config(
test_schema(),
Arc::new(MockAdapter::new(mock_user_results())),
RuntimeConfig::default().with_authorizer(Arc::new(Raising)),
);
let err = executor
.execute_with_security("{ users { id name } }", None, &ctx("u1"))
.await
.unwrap_err();
assert!(is_authz(&err), "raise must fail closed to Authorization/403: {err:?}");
assert!(!format!("{err}").contains("backend down"), "policy error must not leak");
}
#[tokio::test]
async fn authenticated_allow_passes() {
let executor = Executor::with_config(
test_schema(),
Arc::new(MockAdapter::new(mock_user_results())),
RuntimeConfig::default().with_authorizer(Arc::new(AllowAll)),
);
let result = executor
.execute_with_security("{ users { id name } }", None, &ctx("u1"))
.await
.unwrap();
assert_eq!(result["data"]["users"][0]["name"], "Alice");
}
#[tokio::test]
async fn anonymous_deny_returns_403() {
let executor = Executor::with_config(
test_schema(),
Arc::new(MockAdapter::new(mock_user_results())),
RuntimeConfig::default().with_authorizer(Arc::new(DenyAll)),
);
let err = executor.execute("{ users { id name } }", None).await.unwrap_err();
assert!(is_authz(&err), "anon deny must map to Authorization/403: {err:?}");
}
#[tokio::test]
async fn anonymous_principal_is_none_authenticated_is_some() {
let executor = Executor::with_config(
test_schema(),
Arc::new(MockAdapter::new(mock_user_results())),
RuntimeConfig::default().with_authorizer(Arc::new(RequireAuthenticated)),
);
assert!(executor.execute("{ users { id name } }", None).await.is_err());
assert!(
executor
.execute_with_security("{ users { id name } }", None, &ctx("u1"))
.await
.is_ok()
);
}
#[tokio::test]
async fn multi_root_denies_on_any_root() {
let executor = Executor::with_config(
test_schema(),
Arc::new(MockAdapter::new(mock_user_results())),
RuntimeConfig::default().with_authorizer(Arc::new(DenyNamed("secret"))),
);
let err = executor.execute("{ users { id name } secret { id } }", None).await.unwrap_err();
match err {
FraiseQLError::Authorization { resource, .. } => {
assert_eq!(resource.as_deref(), Some("secret"), "denied root is the secret one");
},
other => panic!("expected Authorization, got {other:?}"),
}
}
#[tokio::test]
async fn introspection_is_gated() {
let executor = Executor::with_config(
test_schema(),
Arc::new(MockAdapter::new(vec![])),
RuntimeConfig::default().with_authorizer(Arc::new(DenyAll)),
);
assert!(executor.execute("{ __schema { queryType { name } } }", None).await.is_err());
}
#[tokio::test]
async fn aggregate_is_gated() {
let executor = Executor::with_config(
test_schema(),
Arc::new(MockAdapter::new(vec![])),
RuntimeConfig::default().with_authorizer(Arc::new(DenyAll)),
);
assert!(executor.execute("{ users_aggregate { count } }", None).await.is_err());
}
#[tokio::test]
async fn node_is_gated() {
let executor = Executor::with_config(
test_schema(),
Arc::new(MockAdapter::new(vec![])),
RuntimeConfig::default().with_authorizer(Arc::new(DenyAll)),
);
let res = executor
.execute(r#"{ node(id: "VXNlcjoxMjM=") { ... on User { id } } }"#, None)
.await;
assert!(res.is_err(), "node lookup must route through the op authorizer");
}
#[tokio::test]
async fn allow_does_not_bypass_requires_role() {
let executor = Executor::with_config(
schema_requires_admin(),
Arc::new(MockAdapter::new(mock_user_results())),
RuntimeConfig::default().with_authorizer(Arc::new(AllowAll)),
);
let err = executor
.execute_with_security("{ users { id name } }", None, &ctx("u1")) .await
.unwrap_err();
assert!(
matches!(err, FraiseQLError::Validation { .. }),
"requires_role must stay enumeration-hiding (Validation 'not found'), got {err:?}"
);
assert!(!is_authz(&err), "must NOT regress to Authorization (would leak existence)");
}
#[tokio::test]
async fn deny_wins_over_satisfied_requires_role() {
let executor = Executor::with_config(
schema_requires_admin(),
Arc::new(MockAdapter::new(mock_user_results())),
RuntimeConfig::default().with_authorizer(Arc::new(DenyAll)),
);
let err = executor
.execute_with_security("{ users { id name } }", None, &admin_ctx("admin-1"))
.await
.unwrap_err();
assert!(is_authz(&err), "authorizer deny must win (403), got {err:?}");
}
#[tokio::test]
async fn deny_not_bypassed_by_warm_response_cache() {
let cache = Arc::new(ResponseCache::new(ResponseCacheConfig {
enabled: true,
max_entries: 100,
ttl_seconds: 3600,
}));
let executor = Executor::with_config(
test_schema(),
Arc::new(MockAdapter::new(mock_user_results())),
RuntimeConfig::default().with_authorizer(Arc::new(Flip {
calls: AtomicUsize::new(0),
})),
)
.with_response_cache(cache);
let q = "{ users { id name } }";
assert!(executor.execute_with_security(q, None, &ctx("u1")).await.is_ok());
let err = executor.execute_with_security(q, None, &ctx("u1")).await.unwrap_err();
assert!(is_authz(&err), "a warm cache must not replay an Allow past a later Deny");
}
#[tokio::test]
async fn no_authorizer_is_never_consulted() {
let executor = Executor::with_config(
test_schema(),
Arc::new(MockAdapter::new(mock_user_results())),
RuntimeConfig::default(),
);
assert!(executor.execute("{ users { id name } }", None).await.is_ok());
}
#[tokio::test]
#[should_panic(expected = "authorizer must not be consulted")]
async fn configured_authorizer_is_consulted() {
let executor = Executor::with_config(
test_schema(),
Arc::new(MockAdapter::new(mock_user_results())),
RuntimeConfig::default().with_authorizer(Arc::new(PanicIfCalled)),
);
let _ = executor.execute("{ users { id name } }", None).await;
}
#[tokio::test]
async fn mutation_via_execute_with_security_deny() {
let executor = Executor::with_config(
schema_with_mutation(),
Arc::new(MockAdapter::new(vec![])),
RuntimeConfig::default().with_authorizer(Arc::new(DenyAll)),
);
let err = executor
.execute_with_security("mutation { createUser { id } }", None, &ctx("u1"))
.await
.unwrap_err();
assert!(is_authz(&err), "authenticated mutation deny → 403: {err:?}");
}
#[tokio::test]
async fn mutation_via_anonymous_graphql_deny() {
let executor = Executor::with_config(
schema_with_mutation(),
Arc::new(MockAdapter::new(vec![])),
RuntimeConfig::default().with_authorizer(Arc::new(DenyAll)),
);
let err = executor.execute("mutation { createUser { id } }", None).await.unwrap_err();
assert!(is_authz(&err), "anonymous GraphQL mutation deny → 403: {err:?}");
}
#[tokio::test]
async fn mutation_via_direct_api_deny_closes_anon_rest_bypass() {
let executor = Executor::with_config(
schema_with_mutation(),
Arc::new(MockAdapter::new(vec![])),
RuntimeConfig::default().with_authorizer(Arc::new(DenyAll)),
);
let err = executor.execute_mutation("createUser", None, &[]).await.unwrap_err();
assert!(
is_authz(&err),
"direct execute_mutation deny → 403 (anon-REST bypass closed): {err:?}"
);
}
#[tokio::test]
async fn mutation_raise_fails_closed() {
let executor = Executor::with_config(
schema_with_mutation(),
Arc::new(MockAdapter::new(vec![])),
RuntimeConfig::default().with_authorizer(Arc::new(Raising)),
);
let err = executor.execute_mutation("createUser", None, &[]).await.unwrap_err();
assert!(is_authz(&err), "raising authorizer must fail the mutation closed: {err:?}");
}
#[tokio::test]
async fn mutation_allow_passes_the_gate() {
let executor = Executor::with_config(
schema_with_mutation(),
Arc::new(MockAdapter::new(vec![])),
RuntimeConfig::default().with_authorizer(Arc::new(AllowAll)),
);
let result = executor.execute_mutation("createUser", None, &[]).await;
if let Err(err) = result {
assert!(!is_authz(&err), "AllowAll must not block at the authz gate: {err:?}");
}
}
#[tokio::test]
async fn unknown_mutation_keeps_not_found_with_authorizer() {
let executor = Executor::with_config(
schema_with_mutation(),
Arc::new(MockAdapter::new(vec![])),
RuntimeConfig::default().with_authorizer(Arc::new(DenyAll)),
);
let err = executor.execute_mutation("doesNotExist", None, &[]).await.unwrap_err();
assert!(
matches!(err, FraiseQLError::Validation { .. }),
"unknown mutation must stay 'not found' (Validation), got {err:?}"
);
}
fn users_match() -> crate::runtime::matcher::QueryMatch {
crate::runtime::QueryMatcher::new(test_schema())
.match_query("{ users { id name } }", None)
.unwrap()
}
#[tokio::test]
async fn rest_direct_read_deny_returns_403() {
let executor = Executor::with_config(
test_schema(),
Arc::new(MockAdapter::new(mock_user_results())),
RuntimeConfig::default().with_authorizer(Arc::new(DenyAll)),
);
let qm = users_match();
let err = executor.execute_query_direct(&qm, None, Some(&ctx("u1"))).await.unwrap_err();
assert!(is_authz(&err), "REST direct read deny → 403: {err:?}");
let err = executor.execute_query_direct(&qm, None, None).await.unwrap_err();
assert!(is_authz(&err), "anonymous REST direct read deny → 403: {err:?}");
}
#[tokio::test]
async fn rest_count_rows_deny_returns_403() {
let executor = Executor::with_config(
test_schema(),
Arc::new(MockAdapter::new(mock_user_results())),
RuntimeConfig::default().with_authorizer(Arc::new(DenyAll)),
);
let qm = users_match();
let err = executor.count_rows(&qm, None, Some(&ctx("u1"))).await.unwrap_err();
assert!(is_authz(&err), "REST count_rows deny → 403: {err:?}");
}
#[tokio::test]
async fn rest_direct_read_allow_passes() {
let executor = Executor::with_config(
test_schema(),
Arc::new(MockAdapter::new(mock_user_results())),
RuntimeConfig::default().with_authorizer(Arc::new(AllowAll)),
);
let qm = users_match();
let result = executor.execute_query_direct(&qm, None, Some(&ctx("u1"))).await.unwrap();
assert!(result["users"].is_array() || result.get("data").is_some(), "allowed: {result}");
}
}