use crate::config::{
GraphqlAuth, GraphqlOffsetPagination, GraphqlPagination, GraphqlPaginationSpec,
GraphqlStreamConfig,
};
use async_trait::async_trait;
use base64::Engine as _;
use faucet_core::util::{self, DEFAULT_ERROR_BODY_MAX_LEN};
use faucet_core::{AuthSpec, Credential, FaucetError, SharedAuthProvider, Stream, StreamPage};
use jsonpath_rust::JsonPath;
use reqwest::Client;
use serde_json::{Value, json};
use std::collections::HashMap;
use std::pin::Pin;
use std::time::Duration;
const RETRY_MAX_ATTEMPTS: u32 = 3;
const RETRY_BASE_BACKOFF: Duration = Duration::from_millis(500);
pub struct GraphqlStream {
config: GraphqlStreamConfig,
client: Client,
auth_provider: Option<SharedAuthProvider>,
retry_policy: faucet_core::RetryPolicy,
}
#[cfg(feature = "mtls")]
fn apply_client_tls(
builder: reqwest::ClientBuilder,
tls: &faucet_core::TlsClientConfig,
) -> Result<reqwest::ClientBuilder, FaucetError> {
let identity = build_identity(tls)?;
let mut builder = builder.identity(identity).use_native_tls();
if let Some(v) = &tls.min_version {
let version = if v == "1.3" {
reqwest::tls::Version::TLS_1_3
} else {
reqwest::tls::Version::TLS_1_2
};
builder = builder.min_tls_version(version);
}
Ok(builder)
}
#[cfg(not(feature = "mtls"))]
fn apply_client_tls(
_builder: reqwest::ClientBuilder,
_tls: &faucet_core::TlsClientConfig,
) -> Result<reqwest::ClientBuilder, FaucetError> {
Err(FaucetError::Config(
"a `tls:` (mutual-TLS) block is configured, but this build of \
faucet-source-graphql lacks the `mtls` feature; rebuild with `--features mtls`"
.into(),
))
}
#[cfg(feature = "mtls")]
fn build_identity(tls: &faucet_core::TlsClientConfig) -> Result<reqwest::Identity, FaucetError> {
if let Some(p12_path) = &tls.client_identity_pkcs12 {
let der = std::fs::read(p12_path).map_err(|e| {
FaucetError::Config(format!(
"tls: could not read PKCS#12 file {p12_path:?}: {e}"
))
})?;
let password = tls.pkcs12_password.as_deref().unwrap_or("");
reqwest::Identity::from_pkcs12_der(&der, password)
.map_err(|e| FaucetError::Config(format!("tls: invalid PKCS#12 identity: {e}")))
} else {
let cert = tls.client_cert.as_deref().unwrap_or_default();
let key = tls.client_key.as_deref().unwrap_or_default();
reqwest::Identity::from_pkcs8_pem(cert.as_bytes(), key.as_bytes())
.map_err(|e| FaucetError::Config(format!("tls: invalid PEM client identity: {e}")))
}
}
fn credential_to_auth(cred: Credential) -> GraphqlAuth {
match cred {
Credential::Bearer(token) => GraphqlAuth::Bearer { token },
Credential::Token(token) => GraphqlAuth::Custom {
headers: HashMap::from([("Authorization".into(), token)]),
},
Credential::Header { name, value } => GraphqlAuth::Custom {
headers: HashMap::from([(name, value)]),
},
Credential::Basic { username, password } => GraphqlAuth::Custom {
headers: HashMap::from([(
"Authorization".into(),
format!(
"Basic {}",
base64::engine::general_purpose::STANDARD
.encode(format!("{username}:{password}"))
),
)]),
},
}
}
impl GraphqlStream {
pub fn new(config: GraphqlStreamConfig) -> Self {
Self::try_new(config).expect(
"GraphqlStream::new: client build failed; use try_new() for fallible construction",
)
}
pub fn try_new(config: GraphqlStreamConfig) -> Result<Self, FaucetError> {
let mut builder = Client::builder();
if let Some(tls) = &config.tls {
tls.validate()?;
builder = apply_client_tls(builder, tls)?;
}
let client = builder.build().map_err(|e| {
FaucetError::Config(format!("graphql: failed to build HTTP client: {e}"))
})?;
Ok(Self {
config,
client,
auth_provider: None,
retry_policy: faucet_core::RetryPolicy {
max_attempts: RETRY_MAX_ATTEMPTS + 1,
backoff: faucet_core::BackoffKind::Exponential,
base: RETRY_BASE_BACKOFF,
max: Duration::from_secs(60),
jitter: true,
retry_on: faucet_core::RetryClassSet::default(),
},
})
}
pub fn with_retry_policy(mut self, policy: faucet_core::RetryPolicy) -> Self {
self.retry_policy = policy;
self
}
pub fn with_auth_provider(mut self, provider: SharedAuthProvider) -> Self {
self.auth_provider = Some(provider);
self
}
pub async fn fetch_all(&self) -> Result<Vec<Value>, FaucetError> {
self.fetch_all_with_context(&std::collections::HashMap::new())
.await
}
async fn fetch_all_with_context(
&self,
context: &std::collections::HashMap<String, Value>,
) -> Result<Vec<Value>, FaucetError> {
let mut all_records = Vec::new();
let mut cursor: Option<String> = None;
let mut offset = 0usize;
let mut pages_fetched = 0usize;
let mut warned_unresolved_has_next = false;
let mut cursor_guard = CursorGuard::new();
loop {
if let Some(max) = self.config.max_pages
&& pages_fetched >= max
{
tracing::warn!("max pages ({max}) reached");
break;
}
let body = self.execute_query(&cursor, offset, context).await?;
let records = self.extract_records(&body)?;
let records_in_page = records.len();
all_records.extend(records);
pages_fetched += 1;
match &self.config.pagination {
Some(GraphqlPaginationSpec::Cursor(pag)) => {
let (step, unresolved) = decide_next_page(&body, pag, cursor.as_deref());
if unresolved && !warned_unresolved_has_next {
tracing::warn!(
path = %pag.has_next_page_path,
"GraphQL has_next_page path did not resolve to a boolean; \
deferring to cursor presence to decide pagination"
);
warned_unresolved_has_next = true;
}
match step {
PageStep::Stop => break,
PageStep::StopLoop => {
tracing::warn!("cursor loop detected, stopping pagination");
break;
}
PageStep::Advance(next) => {
if cursor_guard.is_repeat(&next) {
tracing::warn!(
"cursor cycle detected (cursor already seen), stopping pagination"
);
break;
}
cursor = Some(next);
}
}
}
Some(GraphqlPaginationSpec::Offset(off)) => {
if offset_should_continue(records_in_page, off) {
offset += off.page_size;
} else {
break;
}
}
None => break,
}
}
tracing::info!(
records = all_records.len(),
pages = pages_fetched,
"GraphQL fetch complete"
);
Ok(all_records)
}
async fn execute_query(
&self,
cursor: &Option<String>,
offset: usize,
context: &std::collections::HashMap<String, Value>,
) -> Result<Value, FaucetError> {
let mut variables = self.config.variables.clone();
if !context.is_empty()
&& let Value::Object(ref mut map) = variables
{
for (key, value) in context {
map.insert(key.clone(), value.clone());
}
}
let mut query = self.config.query.clone();
match &self.config.pagination {
Some(GraphqlPaginationSpec::Cursor(pag)) => {
if let (Some(cursor_val), Value::Object(map)) = (cursor, &mut variables) {
map.insert(pag.cursor_variable.clone(), json!(cursor_val));
}
if self.config.batch_size != 0
&& let Value::Object(map) = &mut variables
{
map.insert(
pag.page_size_variable.clone(),
json!(self.config.batch_size),
);
}
}
Some(GraphqlPaginationSpec::Offset(off)) => {
if off.substitute_in_query {
let token = format!("${{{}}}", off.offset_variable);
query = query.replace(&token, &offset.to_string());
} else if let Value::Object(map) = &mut variables {
map.insert(off.offset_variable.clone(), json!(offset));
}
}
None => {}
}
let payload = json!({
"query": query,
"variables": variables,
});
let mut req = self
.client
.post(&self.config.endpoint)
.headers(self.config.headers.clone())
.json(&payload);
let effective_auth: GraphqlAuth = if let Some(provider) = &self.auth_provider {
credential_to_auth(provider.credential().await?)
} else {
match &self.config.auth {
AuthSpec::Inline(a) => a.clone(),
AuthSpec::Reference(r) => {
return Err(FaucetError::Auth(format!(
"auth references provider '{}' but no provider was supplied; \
set one via the CLI `auth:` catalog or `with_auth_provider`",
r.name
)));
}
}
};
match effective_auth {
GraphqlAuth::None => {}
GraphqlAuth::Bearer { token } => {
req = req.bearer_auth(token);
}
GraphqlAuth::Custom { headers } => {
let mut hm = reqwest::header::HeaderMap::new();
for (name, value) in &headers {
let n =
reqwest::header::HeaderName::from_bytes(name.as_bytes()).map_err(|e| {
FaucetError::Auth(format!("invalid custom header name {name:?}: {e}"))
})?;
let v = reqwest::header::HeaderValue::from_str(value).map_err(|e| {
FaucetError::Auth(format!("invalid custom header value for {name:?}: {e}"))
})?;
hm.insert(n, v);
}
req = req.headers(hm);
}
}
let body: Value = faucet_core::execute_with_policy(&self.retry_policy, None, || {
let attempt = req.try_clone();
async move {
let req = attempt.ok_or_else(|| {
FaucetError::Source("graphql: request is not cloneable for retry".into())
})?;
let resp = req.send().await.map_err(FaucetError::Http)?;
let resp = util::check_http_response(resp, DEFAULT_ERROR_BODY_MAX_LEN).await?;
resp.json().await.map_err(FaucetError::Http)
}
})
.await?;
if let Some(errors) = body.get("errors")
&& let Some(arr) = errors.as_array()
&& !arr.is_empty()
{
let msg = arr
.iter()
.filter_map(|e| e.get("message").and_then(|m| m.as_str()))
.collect::<Vec<_>>()
.join("; ");
let lower = msg.to_lowercase();
if self.config.batch_size == 0
&& let Some(GraphqlPaginationSpec::Cursor(pag)) = &self.config.pagination
{
let var_name = pag.page_size_variable.to_lowercase();
if lower.contains(&var_name)
&& (lower.contains("non-null")
|| lower.contains("non null")
|| lower.contains("must not be null")
|| lower.contains("cannot be null")
|| lower.contains("required"))
{
return Err(FaucetError::Config(format!(
"batch_size = 0 requires the upstream to accept a null {}: argument \
(GraphQL errors: {msg})",
pag.page_size_variable
)));
}
}
return Err(FaucetError::HttpStatus {
status: 200,
url: self.config.endpoint.clone(),
body: format!("GraphQL errors: {msg}"),
});
}
Ok(body)
}
fn extract_records(&self, body: &Value) -> Result<Vec<Value>, FaucetError> {
match &self.config.records_path {
Some(path) => util::extract_records(body, Some(path)),
None => {
match body.get("data") {
Some(Value::Null) | None => Ok(Vec::new()),
Some(data) => Ok(vec![data.clone()]),
}
}
}
}
fn stream_pages_inner(
&self,
context: &std::collections::HashMap<String, Value>,
) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + '_>> {
let owned_context: std::collections::HashMap<String, Value> = context.clone();
Box::pin(async_stream::try_stream! {
let mut cursor: Option<String> = None;
let mut offset = 0usize;
let mut cursor_guard = CursorGuard::new();
let mut pages_fetched = 0usize;
let mut warned_unresolved_has_next = false;
let running_max: Option<Value> = None;
let mut bookmark_emitted = false;
loop {
if let Some(max) = self.config.max_pages
&& pages_fetched >= max
{
tracing::warn!("max pages ({max}) reached");
break;
}
let body = self.execute_query(&cursor, offset, &owned_context).await?;
let records = self.extract_records(&body)?;
let records_in_page = records.len();
pages_fetched += 1;
let has_next = match &self.config.pagination {
Some(GraphqlPaginationSpec::Cursor(pag)) => {
let (step, unresolved) =
decide_next_page(&body, pag, cursor.as_deref());
if unresolved && !warned_unresolved_has_next {
tracing::warn!(
path = %pag.has_next_page_path,
"GraphQL has_next_page path did not resolve to a boolean; \
deferring to cursor presence to decide pagination"
);
warned_unresolved_has_next = true;
}
match step {
PageStep::Stop => false,
PageStep::StopLoop => {
tracing::warn!("cursor loop detected, stopping pagination");
false
}
PageStep::Advance(next) => {
if cursor_guard.is_repeat(&next) {
tracing::warn!(
"cursor cycle detected (cursor already seen), stopping pagination"
);
false
} else {
cursor = Some(next);
true
}
}
}
}
Some(GraphqlPaginationSpec::Offset(off)) => {
let advance = offset_should_continue(records_in_page, off);
if advance {
offset += off.page_size;
}
advance
}
None => false,
};
if has_next {
yield StreamPage { records, bookmark: None };
} else {
bookmark_emitted = running_max.is_some();
yield StreamPage {
records,
bookmark: running_max.clone(),
};
break;
}
}
if !bookmark_emitted && running_max.is_some() {
yield StreamPage {
records: Vec::new(),
bookmark: running_max,
};
}
tracing::info!(
pages = pages_fetched,
batch_size = self.config.batch_size,
"GraphQL source stream complete",
);
})
}
}
#[async_trait]
impl faucet_core::Source for GraphqlStream {
async fn fetch_with_context(
&self,
context: &std::collections::HashMap<String, serde_json::Value>,
) -> Result<Vec<Value>, FaucetError> {
self.fetch_all_with_context(context).await
}
fn stream_pages<'a>(
&'a self,
context: &'a std::collections::HashMap<String, Value>,
_batch_size: usize,
) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
self.stream_pages_inner(context)
}
fn connector_name(&self) -> &'static str {
"graphql"
}
fn config_schema(&self) -> serde_json::Value {
serde_json::to_value(faucet_core::schema_for!(GraphqlStreamConfig))
.expect("schema serialization")
}
fn dataset_uri(&self) -> String {
faucet_core::redact_uri_credentials(&self.config.endpoint)
}
}
fn extract_string(body: &Value, path: &str) -> Option<String> {
let results = body.query(path).ok()?;
match results.first()? {
Value::String(s) => Some(s.clone()),
_ => None,
}
}
fn extract_bool(body: &Value, path: &str) -> Option<bool> {
let results = body.query(path).ok()?;
results.first()?.as_bool()
}
#[derive(Debug, PartialEq)]
enum PageStep {
Stop,
StopLoop,
Advance(String),
}
fn decide_next_page(
body: &Value,
pag: &GraphqlPagination,
prev_cursor: Option<&str>,
) -> (PageStep, bool) {
let (stop, unresolved) = match extract_bool(body, &pag.has_next_page_path) {
Some(false) => (true, false),
Some(true) => (false, false),
None => (false, true),
};
if stop {
return (PageStep::Stop, unresolved);
}
match extract_string(body, &pag.cursor_path) {
None => (PageStep::Stop, unresolved),
Some(next) if Some(next.as_str()) == prev_cursor => (PageStep::StopLoop, unresolved),
Some(next) => (PageStep::Advance(next), unresolved),
}
}
fn offset_should_continue(records_in_page: usize, off: &GraphqlOffsetPagination) -> bool {
if records_in_page == 0 {
return false;
}
if off.stop_when_short && records_in_page < off.page_size {
return false;
}
true
}
struct CursorGuard {
seen: HashMap<String, ()>,
order: std::collections::VecDeque<String>,
}
impl CursorGuard {
const CAP: usize = 4096;
fn new() -> Self {
Self {
seen: HashMap::new(),
order: std::collections::VecDeque::new(),
}
}
fn is_repeat(&mut self, cursor: &str) -> bool {
if self.seen.contains_key(cursor) {
return true;
}
if self.order.len() >= Self::CAP
&& let Some(old) = self.order.pop_front()
{
self.seen.remove(&old);
}
self.seen.insert(cursor.to_string(), ());
self.order.push_back(cursor.to_string());
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_string_from_json() {
let body = json!({"data": {"users": {"pageInfo": {"endCursor": "abc123"}}}});
assert_eq!(
extract_string(&body, "$.data.users.pageInfo.endCursor"),
Some("abc123".into())
);
}
#[test]
fn extract_bool_from_json() {
let body = json!({"data": {"users": {"pageInfo": {"hasNextPage": true}}}});
assert_eq!(
extract_bool(&body, "$.data.users.pageInfo.hasNextPage"),
Some(true)
);
}
fn pageinfo_pagination() -> GraphqlPagination {
GraphqlPagination {
has_next_page_path: "$.data.users.pageInfo.hasNextPage".into(),
cursor_path: "$.data.users.pageInfo.endCursor".into(),
..GraphqlPagination::default()
}
}
#[test]
fn decide_next_page_advances_when_has_next_true() {
let body =
json!({"data": {"users": {"pageInfo": {"hasNextPage": true, "endCursor": "c2"}}}});
let (step, unresolved) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
assert_eq!(step, PageStep::Advance("c2".into()));
assert!(!unresolved);
}
#[test]
fn decide_next_page_stops_when_has_next_false() {
let body =
json!({"data": {"users": {"pageInfo": {"hasNextPage": false, "endCursor": "c2"}}}});
let (step, unresolved) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
assert_eq!(step, PageStep::Stop);
assert!(!unresolved);
}
#[test]
fn decide_next_page_detects_cursor_loop() {
let body =
json!({"data": {"users": {"pageInfo": {"hasNextPage": true, "endCursor": "c1"}}}});
let (step, _) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
assert_eq!(step, PageStep::StopLoop);
}
#[test]
fn decide_next_page_defers_to_cursor_when_has_next_unresolved() {
let body = json!({"data": {"users": {"pageInfo": {"endCursor": "c2"}}}}); let (step, unresolved) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
assert_eq!(
step,
PageStep::Advance("c2".into()),
"unresolved has-next must defer to cursor presence, not stop"
);
assert!(unresolved, "the caller is told to warn once");
let body_no_cursor = json!({"data": {"users": {"pageInfo": {}}}});
let (step, unresolved) =
decide_next_page(&body_no_cursor, &pageinfo_pagination(), Some("c1"));
assert_eq!(step, PageStep::Stop);
assert!(unresolved);
}
fn offset_pagination(page_size: usize, stop_when_short: bool) -> GraphqlOffsetPagination {
GraphqlOffsetPagination {
r#type: crate::config::OffsetPaginationKind::Offset,
offset_variable: "q_offset".into(),
page_size,
stop_when_short,
substitute_in_query: false,
}
}
#[test]
fn offset_continues_on_full_page() {
assert!(offset_should_continue(250, &offset_pagination(250, true)));
}
#[test]
fn offset_stops_on_short_page_when_stop_when_short() {
assert!(!offset_should_continue(100, &offset_pagination(250, true)));
}
#[test]
fn offset_continues_on_short_page_when_not_stop_when_short() {
assert!(offset_should_continue(100, &offset_pagination(250, false)));
}
#[test]
fn offset_always_stops_on_empty_page() {
assert!(!offset_should_continue(0, &offset_pagination(250, true)));
assert!(!offset_should_continue(0, &offset_pagination(250, false)));
}
#[test]
fn offset_exact_page_size_is_full_not_short() {
assert!(offset_should_continue(1, &offset_pagination(1, true)));
}
#[test]
fn extract_records_with_path() {
let config =
GraphqlStreamConfig::new("https://api.example.com/graphql", "query { users { id } }")
.records_path("$.data.users[*]");
let stream = GraphqlStream::new(config);
let body = json!({"data": {"users": [{"id": 1}, {"id": 2}]}});
let records = stream.extract_records(&body).unwrap();
assert_eq!(records.len(), 2);
assert_eq!(records[0]["id"], 1);
}
#[test]
fn extract_records_without_path_returns_data() {
let config =
GraphqlStreamConfig::new("https://api.example.com/graphql", "query { user { id } }");
let stream = GraphqlStream::new(config);
let body = json!({"data": {"user": {"id": 1}}});
let records = stream.extract_records(&body).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0]["user"]["id"], 1);
}
#[test]
fn extract_records_without_path_null_data_yields_empty() {
let config =
GraphqlStreamConfig::new("https://api.example.com/graphql", "query { user { id } }");
let stream = GraphqlStream::new(config);
let body = json!({ "data": null });
let records = stream.extract_records(&body).unwrap();
assert!(
records.is_empty(),
"expected empty Vec for null `data`, got {records:?}"
);
}
#[test]
fn extract_records_without_path_absent_data_yields_empty() {
let config =
GraphqlStreamConfig::new("https://api.example.com/graphql", "query { user { id } }");
let stream = GraphqlStream::new(config);
let body = json!({ "extensions": { "foo": 1 } });
let records = stream.extract_records(&body).unwrap();
assert!(
records.is_empty(),
"expected empty Vec when `data` is absent, got {records:?}"
);
}
#[test]
fn dataset_uri_returns_endpoint() {
use faucet_core::Source;
let stream = GraphqlStream::new(GraphqlStreamConfig::new(
"https://api.example.com/graphql",
"query { id }",
));
assert_eq!(stream.dataset_uri(), "https://api.example.com/graphql");
}
#[test]
fn dataset_uri_redacts_credentials() {
use faucet_core::Source;
let stream = GraphqlStream::new(GraphqlStreamConfig::new(
"https://user:pw@api.example.com/graphql",
"query { id }",
));
assert_eq!(stream.dataset_uri(), "https://api.example.com/graphql");
}
#[test]
fn default_retry_policy_reproduces_legacy_constants() {
let stream = GraphqlStream::new(GraphqlStreamConfig::new(
"https://api.example.com/graphql",
"query { id }",
));
assert_eq!(stream.retry_policy.max_attempts, RETRY_MAX_ATTEMPTS + 1);
assert_eq!(stream.retry_policy.base, RETRY_BASE_BACKOFF);
}
#[test]
fn with_retry_policy_overrides_the_default() {
let policy = faucet_core::RetryPolicy {
max_attempts: 9,
base: Duration::from_secs(7),
..faucet_core::RetryPolicy::default()
};
let stream = GraphqlStream::new(GraphqlStreamConfig::new(
"https://api.example.com/graphql",
"query { id }",
))
.with_retry_policy(policy);
assert_eq!(stream.retry_policy.max_attempts, 9);
assert_eq!(stream.retry_policy.base, Duration::from_secs(7));
}
#[test]
fn cursor_guard_detects_repeats_and_bounds_memory() {
let mut g = CursorGuard::new();
assert!(!g.is_repeat("a"));
assert!(!g.is_repeat("b"));
assert!(g.is_repeat("a"));
assert!(g.is_repeat("b"));
let mut g = CursorGuard::new();
for i in 0..CursorGuard::CAP {
assert!(!g.is_repeat(&format!("c{i}")));
}
assert_eq!(g.order.len(), CursorGuard::CAP);
assert!(!g.is_repeat("overflow"));
assert_eq!(g.order.len(), CursorGuard::CAP);
assert!(!g.seen.contains_key("c0"), "oldest cursor evicted");
assert!(g.seen.contains_key("overflow"));
}
}
#[cfg(all(test, feature = "mtls"))]
mod mtls_tests {
use super::*;
use faucet_core::TlsClientConfig;
const CERT: &str = include_str!("../tests/fixtures/mtls/cert.pem");
const KEY: &str = include_str!("../tests/fixtures/mtls/key.pem");
fn pem() -> TlsClientConfig {
TlsClientConfig {
client_cert: Some(CERT.to_string()),
client_key: Some(KEY.to_string()),
..Default::default()
}
}
fn cfg(tls: TlsClientConfig) -> GraphqlStreamConfig {
GraphqlStreamConfig::new("https://x.test/graphql", "{ ping }").tls(tls)
}
#[test]
fn pem_identity_builds() {
assert!(GraphqlStream::try_new(cfg(pem())).is_ok());
}
#[test]
fn min_version_branches_are_exercised() {
let mut tls = pem();
tls.min_version = Some("1.2".into());
assert!(GraphqlStream::try_new(cfg(tls)).is_ok());
let mut tls = pem();
tls.min_version = Some("1.3".into());
let _ = GraphqlStream::try_new(cfg(tls));
}
#[test]
fn pkcs12_identity_builds() {
let p12 = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/mtls/identity.p12"
);
let tls = TlsClientConfig {
client_identity_pkcs12: Some(p12.to_string()),
pkcs12_password: Some("changeit".into()),
..Default::default()
};
assert!(GraphqlStream::try_new(cfg(tls)).is_ok());
}
#[test]
fn invalid_pem_errors_without_leaking_key() {
let tls = TlsClientConfig {
client_cert: Some("-----BEGIN CERTIFICATE-----\nbad\n-----END CERTIFICATE-----".into()),
client_key: Some("SUPERSECRETKEY".into()),
..Default::default()
};
let err = GraphqlStream::try_new(cfg(tls))
.map(|_| ())
.expect_err("bad PEM must error");
assert!(!err.to_string().contains("SUPERSECRETKEY"));
}
#[test]
fn invalid_tls_shape_errors() {
let mut tls = pem();
tls.client_identity_pkcs12 = Some("/x.p12".into());
assert!(GraphqlStream::try_new(cfg(tls)).is_err());
}
#[test]
fn missing_pkcs12_file_errors() {
let tls = TlsClientConfig {
client_identity_pkcs12: Some("/no/such.p12".into()),
pkcs12_password: Some("x".into()),
..Default::default()
};
assert!(GraphqlStream::try_new(cfg(tls)).is_err());
}
#[test]
fn config_validate_checks_tls() {
assert!(cfg(pem()).validate().is_ok());
let mut bad = pem();
bad.client_identity_pkcs12 = Some("/x.p12".into());
assert!(cfg(bad).validate().is_err());
}
}