#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::sync::Arc;
use fraiseql_arrow::QueryExecutor;
use fraiseql_core::{schema::CompiledSchema, security::SecurityContext, types::UserId};
use fraiseql_test_utils::failing_adapter::FailingAdapter;
use super::policy_seam::PolicyGatedExecutor;
use crate::{server::Server, server_config::ServerConfig};
const PERSISTED_DOC: &str = "{ users { id } }";
const ADHOC_DOC: &str = "{ adhoc { id } }";
async fn persisted_only_server(dir: &tempfile::TempDir) -> Server {
use sha2::Digest as _;
let hash = hex::encode(sha2::Sha256::digest(PERSISTED_DOC.as_bytes()));
let manifest = serde_json::json!({
"version": 1,
"documents": { format!("sha256:{hash}"): PERSISTED_DOC }
});
let manifest_path = dir.path().join("manifest.json");
std::fs::write(&manifest_path, serde_json::to_string(&manifest).unwrap()).unwrap();
let mut schema = CompiledSchema::new();
schema.security = Some(fraiseql_core::schema::SecurityConfig {
persisted_queries_only: true,
trusted_documents: Some(fraiseql_core::schema::TrustedDocumentsConfig {
enabled: true,
mode: fraiseql_core::schema::TrustedDocumentMode::Permissive,
manifest_path: Some(manifest_path.to_str().unwrap().to_string()),
..Default::default()
}),
..fraiseql_core::schema::SecurityConfig::default()
});
let config = ServerConfig {
cors_enabled: false,
..ServerConfig::default()
};
Box::pin(Server::new(config, schema, Arc::new(FailingAdapter::new()), None))
.await
.expect("Server::new should succeed with a trusted-documents manifest")
}
fn flight_session_context() -> SecurityContext {
let user = fraiseql_core::security::auth_middleware::AuthenticatedUser {
user_id: UserId::new("flight-policy-user"),
scopes: vec!["user".to_string()],
expires_at: chrono::Utc::now() + chrono::Duration::hours(1),
email: None,
display_name: None,
extra_claims: std::collections::HashMap::new(),
};
SecurityContext::from_user(&user, "req-flight-1".to_string())
}
#[tokio::test]
async fn an_adhoc_document_is_refused_by_the_persisted_only_policy() {
let dir = tempfile::tempdir().unwrap();
let server = persisted_only_server(&dir).await;
let seam = PolicyGatedExecutor::new(server.build_app_state());
let error = seam
.execute_with_security(ADHOC_DOC, None, &flight_session_context())
.await
.expect_err("persisted-only mode must refuse an ad-hoc document over Flight too");
assert!(
matches!(error, fraiseql_core::error::FraiseQLError::Authorization { .. }),
"a policy refusal is an authorization decision, got: {error:?}"
);
let message = error.to_string();
assert!(
message.contains("persisted queries only"),
"the refusal must name the policy that refused, got: {message}"
);
assert!(
!message.contains("No executor configured"),
"an unwired transport is not a policy decision, got: {message}"
);
}
#[tokio::test]
async fn under_persisted_only_even_the_allow_listed_text_is_refused_over_flight() {
let dir = tempfile::tempdir().unwrap();
let server = persisted_only_server(&dir).await;
let seam = PolicyGatedExecutor::new(server.build_app_state());
let error = seam
.execute_with_security(PERSISTED_DOC, None, &flight_session_context())
.await
.expect_err("strict mode resolves by document ID; matching text is not a substitute");
assert!(
matches!(error, fraiseql_core::error::FraiseQLError::Authorization { .. }),
"a policy refusal is an authorization decision, got: {error:?}"
);
assert!(
error.to_string().contains("persisted queries only"),
"the refusal must name the policy, got: {error}"
);
}
#[tokio::test]
async fn without_a_trusted_document_store_the_query_reaches_execution() {
let server = Box::pin(Server::new(
ServerConfig {
cors_enabled: false,
..ServerConfig::default()
},
CompiledSchema::new(),
Arc::new(FailingAdapter::new()),
None,
))
.await
.expect("Server::new should succeed without trusted documents");
let seam = PolicyGatedExecutor::new(server.build_app_state());
let outcome = seam.execute_with_security(PERSISTED_DOC, None, &flight_session_context()).await;
if let Err(error) = outcome {
let message = error.to_string();
assert!(
!message.contains("persisted queries only"),
"with no store configured nothing may be refused as unpersisted, got: {message}"
);
assert!(
!message.to_lowercase().contains("tenant"),
"an unconfigured single-tenant deployment must not be refused by tenancy, got: \
{message}"
);
assert!(
message.contains("users"),
"the failure must come from executing the document, not from a gate before it, \
got: {message}"
);
}
}
#[tokio::test]
async fn the_policy_seam_attaches_to_a_flight_service() {
let dir = tempfile::tempdir().unwrap();
let server = persisted_only_server(&dir).await;
let mut service = fraiseql_arrow::FraiseQLFlightService::new();
assert!(
!service.has_executor(),
"precondition: a freshly built Flight service refuses GraphQL for want of an executor"
);
service.set_executor(super::policy_seam::policy_gated_executor(server.build_app_state()));
assert!(
service.has_executor(),
"after the serve-time attach the Flight service executes GraphQL — through the seam"
);
}
async fn failing_server(sanitised: bool) -> Server {
let mut schema = fraiseql_test_utils::schema_builder::TestSchemaBuilder::new()
.with_simple_query("users", "User", true)
.build();
schema.security = Some(fraiseql_core::schema::SecurityConfig {
error_sanitization: Some(fraiseql_core::schema::ErrorSanitizationConfig {
enabled: sanitised,
..fraiseql_core::schema::ErrorSanitizationConfig::default()
}),
..fraiseql_core::schema::SecurityConfig::default()
});
let adapter = FailingAdapter::new().fail_with_error(
fraiseql_test_utils::failing_adapter::FailError::Database {
message: "relation \"internal_audit_shadow\" does not exist".to_string(),
sql_state: Some("42P01".to_string()),
},
);
let config = ServerConfig {
cors_enabled: false,
..ServerConfig::default()
};
Box::pin(Server::new(config, schema, Arc::new(adapter), None))
.await
.expect("Server::new")
}
#[tokio::test]
async fn a_database_error_over_flight_is_sanitised_as_graphql_sanitises_it() {
let seam = PolicyGatedExecutor::new(failing_server(true).await.build_app_state());
let error = seam
.execute_with_security("{ users { id } }", None, &flight_session_context())
.await
.expect_err("the adapter fails");
assert!(
matches!(error, fraiseql_core::error::FraiseQLError::Database { .. }),
"the kind, and so the gRPC status, is kept: {error:?}"
);
assert!(
!error.to_string().contains("internal_audit_shadow"),
"the database's text reached the Flight client: {error}"
);
}
#[tokio::test]
async fn an_unsanitised_database_error_over_flight_keeps_its_text() {
let seam = PolicyGatedExecutor::new(failing_server(false).await.build_app_state());
let error = seam
.execute_with_security("{ users { id } }", None, &flight_session_context())
.await
.expect_err("the adapter fails");
assert!(error.to_string().contains("internal_audit_shadow"), "{error}");
}