1use crate::config::{XmlAuth, XmlPagination, XmlStreamConfig};
4use crate::convert;
5use async_trait::async_trait;
6use faucet_core::util::{self, DEFAULT_ERROR_BODY_MAX_LEN};
7use faucet_core::{AuthSpec, Credential, FaucetError, SharedAuthProvider};
8use faucet_core::{Stream, StreamPage};
9use reqwest::Client;
10use serde_json::Value;
11use std::collections::HashMap;
12use std::pin::Pin;
13use std::time::Duration;
14
15fn page_fingerprint(records: &[Value]) -> u64 {
21 use std::hash::{Hash, Hasher};
22 let mut hasher = std::collections::hash_map::DefaultHasher::new();
24 records.len().hash(&mut hasher);
25 for r in records {
26 r.to_string().hash(&mut hasher);
27 }
28 hasher.finish()
29}
30
31const RETRY_MAX_ATTEMPTS: u32 = 3;
33const RETRY_BASE_BACKOFF: Duration = Duration::from_millis(500);
35
36pub struct XmlStream {
38 config: XmlStreamConfig,
39 client: Client,
40 auth_provider: Option<SharedAuthProvider>,
45 retry_policy: faucet_core::RetryPolicy,
49}
50
51#[cfg(feature = "mtls")]
55fn apply_client_tls(
56 builder: reqwest::ClientBuilder,
57 tls: &faucet_core::TlsClientConfig,
58) -> Result<reqwest::ClientBuilder, FaucetError> {
59 let identity = build_identity(tls)?;
60 let mut builder = builder.identity(identity).use_native_tls();
61 if let Some(v) = &tls.min_version {
62 let version = if v == "1.3" {
64 reqwest::tls::Version::TLS_1_3
65 } else {
66 reqwest::tls::Version::TLS_1_2
67 };
68 builder = builder.min_tls_version(version);
69 }
70 Ok(builder)
71}
72
73#[cfg(not(feature = "mtls"))]
74fn apply_client_tls(
75 _builder: reqwest::ClientBuilder,
76 _tls: &faucet_core::TlsClientConfig,
77) -> Result<reqwest::ClientBuilder, FaucetError> {
78 Err(FaucetError::Config(
79 "a `tls:` (mutual-TLS) block is configured, but this build of \
80 faucet-source-xml lacks the `mtls` feature; rebuild with `--features mtls`"
81 .into(),
82 ))
83}
84
85#[cfg(feature = "mtls")]
88fn build_identity(tls: &faucet_core::TlsClientConfig) -> Result<reqwest::Identity, FaucetError> {
89 if let Some(p12_path) = &tls.client_identity_pkcs12 {
90 let der = std::fs::read(p12_path).map_err(|e| {
91 FaucetError::Config(format!(
92 "tls: could not read PKCS#12 file {p12_path:?}: {e}"
93 ))
94 })?;
95 let password = tls.pkcs12_password.as_deref().unwrap_or("");
96 reqwest::Identity::from_pkcs12_der(&der, password)
97 .map_err(|e| FaucetError::Config(format!("tls: invalid PKCS#12 identity: {e}")))
98 } else {
99 let cert = tls.client_cert.as_deref().unwrap_or_default();
100 let key = tls.client_key.as_deref().unwrap_or_default();
101 reqwest::Identity::from_pkcs8_pem(cert.as_bytes(), key.as_bytes())
102 .map_err(|e| FaucetError::Config(format!("tls: invalid PEM client identity: {e}")))
103 }
104}
105
106fn credential_to_auth(cred: Credential) -> XmlAuth {
109 match cred {
110 Credential::Bearer(token) => XmlAuth::Bearer { token },
111 Credential::Token(token) => XmlAuth::Custom {
112 headers: std::iter::once(("Authorization".to_string(), token)).collect(),
113 },
114 Credential::Basic { username, password } => XmlAuth::Basic { username, password },
115 Credential::Header { name, value } => XmlAuth::Custom {
116 headers: std::iter::once((name, value)).collect(),
117 },
118 }
119}
120
121impl XmlStream {
122 pub fn new(config: XmlStreamConfig) -> Self {
129 Self::try_new(config)
130 .expect("XmlStream::new: client build failed; use try_new() for fallible construction")
131 }
132
133 pub fn try_new(config: XmlStreamConfig) -> Result<Self, FaucetError> {
142 let mut builder = Client::builder();
143 if let Some(tls) = &config.tls {
144 tls.validate()?;
145 builder = apply_client_tls(builder, tls)?;
146 }
147 let client = builder
148 .build()
149 .map_err(|e| FaucetError::Config(format!("xml: failed to build HTTP client: {e}")))?;
150 Ok(Self {
151 config,
152 client,
153 auth_provider: None,
154 retry_policy: faucet_core::RetryPolicy {
158 max_attempts: RETRY_MAX_ATTEMPTS + 1,
159 backoff: faucet_core::BackoffKind::Exponential,
160 base: RETRY_BASE_BACKOFF,
161 max: Duration::from_secs(60),
162 jitter: true,
163 retry_on: faucet_core::RetryClassSet::default(),
164 },
165 })
166 }
167
168 pub fn with_retry_policy(mut self, policy: faucet_core::RetryPolicy) -> Self {
173 self.retry_policy = policy;
174 self
175 }
176
177 pub fn with_auth_provider(mut self, provider: SharedAuthProvider) -> Self {
184 self.auth_provider = Some(provider);
185 self
186 }
187
188 fn effective_records_path(&self) -> Option<String> {
196 match (&self.config.soap, &self.config.records_element_path) {
197 (Some(soap), Some(path)) if soap.path_relative_to_body => {
198 Some(format!("Envelope.Body.{path}"))
199 }
200 (_, path) => path.clone(),
201 }
202 }
203
204 fn extract_records_eager(
214 &self,
215 xml_text: &str,
216 fault_logged: &mut bool,
217 ) -> Result<Vec<Value>, FaucetError> {
218 let doc = convert::xml_to_json(xml_text)?;
219
220 if let Some(soap) = &self.config.soap
221 && let Some(message) = convert::detect_soap_fault(&doc)
222 {
223 if soap.fault_as_error {
224 return Err(FaucetError::Source(format!("SOAP fault: {message}")));
225 }
226 if !*fault_logged {
227 tracing::warn!(
228 fault = %message,
229 "SOAP fault in response; emitting zero records (fault_as_error=false)"
230 );
231 *fault_logged = true;
232 }
233 return Ok(Vec::new());
234 }
235
236 let records = match self.effective_records_path() {
237 Some(path) => convert::extract_at_path(&doc, &path),
238 None => vec![doc],
239 };
240 Ok(records)
241 }
242
243 pub async fn fetch_all(&self) -> Result<Vec<Value>, FaucetError> {
245 self.fetch_all_with_context(&HashMap::new()).await
246 }
247
248 async fn fetch_all_with_context(
250 &self,
251 context: &HashMap<String, serde_json::Value>,
252 ) -> Result<Vec<Value>, FaucetError> {
253 self.config.validate()?;
254
255 let mut all_records = Vec::new();
256 let mut pages_fetched = 0usize;
257 let mut offset = 0usize;
258 let mut page_number = None;
259 let mut prev_fingerprint: Option<u64> = None;
260 let mut fault_logged = false;
261 let mut body_override: Option<String> = None;
265 let mut prev_token: Option<String> = None;
266
267 if let Some(XmlPagination::PageNumber { start_page, .. }) = &self.config.pagination {
269 page_number = Some(*start_page);
270 }
271
272 loop {
273 if let Some(max) = self.config.max_pages
274 && pages_fetched >= max
275 {
276 tracing::warn!("max pages ({max}) reached");
277 break;
278 }
279
280 let mut params = self.config.query_params.clone();
281 self.apply_pagination_params(&mut params, page_number, offset);
282
283 let xml_text = self
284 .execute_request(¶ms, context, body_override.as_deref())
285 .await?;
286 let records = if self.config.decode.is_empty() {
290 self.extract_records_eager(&xml_text, &mut fault_logged)?
291 } else {
292 crate::decode::run_decode(xml_text.as_bytes(), &self.config.decode).await?
293 };
294
295 let record_count = records.len();
296 let fingerprint = page_fingerprint(&records);
297 pages_fetched += 1;
298
299 if record_count > 0 && prev_fingerprint == Some(fingerprint) {
305 tracing::warn!(
306 "XML pagination returned an identical page; stopping to avoid an infinite loop"
307 );
308 break;
309 }
310 prev_fingerprint = Some(fingerprint);
311 all_records.extend(records);
312
313 match &self.config.pagination {
315 Some(XmlPagination::PageNumber { page_size, .. }) => {
316 if record_count == 0 {
317 break;
318 }
319 if let Some(size) = page_size
321 && record_count < *size
322 {
323 break;
324 }
325 page_number = page_number.map(|p| p + 1);
326 }
327 Some(XmlPagination::Offset { limit, .. }) => {
328 if record_count < *limit {
329 break;
330 }
331 offset += record_count;
332 }
333 Some(XmlPagination::BodyCursor {
334 next_token_path,
335 next_body,
336 }) => {
337 match crate::decode::xml_extract_text(xml_text.as_bytes(), next_token_path) {
339 Some(t)
341 if !t.trim().is_empty()
342 && prev_token.as_deref() != Some(t.as_str()) =>
343 {
344 body_override = Some(next_body.replace("${next_token}", &t));
345 prev_token = Some(t);
346 }
347 _ => break,
348 }
349 }
350 None => break,
351 }
352 }
353
354 tracing::info!(
355 records = all_records.len(),
356 pages = pages_fetched,
357 "XML fetch complete"
358 );
359 Ok(all_records)
360 }
361
362 fn apply_pagination_params(
363 &self,
364 params: &mut HashMap<String, String>,
365 page_number: Option<usize>,
366 offset: usize,
367 ) {
368 match &self.config.pagination {
369 Some(XmlPagination::PageNumber {
370 param_name,
371 page_size,
372 page_size_param,
373 ..
374 }) => {
375 if let Some(page) = page_number {
376 params.insert(param_name.clone(), page.to_string());
377 }
378 if let (Some(size), Some(param)) = (page_size, page_size_param) {
379 params.insert(param.clone(), size.to_string());
380 }
381 }
382 Some(XmlPagination::Offset {
383 offset_param,
384 limit_param,
385 limit,
386 }) => {
387 params.insert(offset_param.clone(), offset.to_string());
388 params.insert(limit_param.clone(), limit.to_string());
389 }
390 Some(XmlPagination::BodyCursor { .. }) => {}
393 None => {}
394 }
395 }
396
397 async fn execute_request(
398 &self,
399 params: &HashMap<String, String>,
400 context: &HashMap<String, serde_json::Value>,
401 body_override: Option<&str>,
402 ) -> Result<String, FaucetError> {
403 let path = if context.is_empty() {
404 self.config.path.clone()
405 } else {
406 faucet_core::util::substitute_context(&self.config.path, context)
407 };
408
409 let url = format!("{}/{}", self.config.base_url, path.trim_start_matches('/'));
410
411 let resolved_params: HashMap<String, String> = if context.is_empty() {
413 params.clone()
414 } else {
415 params
416 .iter()
417 .map(|(k, v)| (k.clone(), faucet_core::util::substitute_context(v, context)))
418 .collect()
419 };
420
421 let mut req = self
422 .client
423 .request(self.config.method.clone(), &url)
424 .headers(self.config.headers.clone())
425 .query(&resolved_params);
426
427 let effective_auth: XmlAuth = if let Some(provider) = &self.auth_provider {
431 credential_to_auth(provider.credential().await?)
432 } else {
433 match &self.config.auth {
434 AuthSpec::Inline(a) => a.clone(),
435 AuthSpec::Reference(r) => {
436 return Err(FaucetError::Auth(format!(
437 "auth references provider '{}' but no provider was supplied; \
438 set one via the CLI `auth:` catalog or `with_auth_provider`",
439 r.name
440 )));
441 }
442 }
443 };
444
445 match &effective_auth {
447 XmlAuth::None => {}
448 XmlAuth::Bearer { token } => {
449 req = req.bearer_auth(token);
450 }
451 XmlAuth::Basic { username, password } => {
452 req = req.basic_auth(username, Some(password));
453 }
454 XmlAuth::Custom { headers } => {
455 let mut hm = reqwest::header::HeaderMap::new();
456 for (name, value) in headers {
457 let n =
458 reqwest::header::HeaderName::from_bytes(name.as_bytes()).map_err(|e| {
459 FaucetError::Auth(format!("invalid custom header name {name:?}: {e}"))
460 })?;
461 let v = reqwest::header::HeaderValue::from_str(value).map_err(|e| {
462 FaucetError::Auth(format!("invalid custom header value for {name:?}: {e}"))
463 })?;
464 hm.insert(n, v);
465 }
466 req = req.headers(hm);
467 }
468 }
469
470 if let Some(ob) = body_override {
478 let resolved = if context.is_empty() {
482 ob.to_string()
483 } else {
484 faucet_core::util::substitute_context(ob, context)
485 };
486 req = req
487 .header("Content-Type", "text/xml; charset=utf-8")
488 .body(resolved);
489 } else if let Some(soap) = &self.config.soap {
490 let inner = soap.body_inner.as_deref().unwrap_or("");
491 let resolved_inner = if context.is_empty() {
492 inner.to_string()
493 } else {
494 faucet_core::util::substitute_context(inner, context)
495 };
496 let envelope = soap.build_envelope(&resolved_inner);
497 req = req
498 .header("Content-Type", soap.content_type())
499 .body(envelope);
500 if let Some(action) = soap.soap_action_header() {
501 req = req.header("SOAPAction", action);
502 }
503 } else if let Some(body) = &self.config.body {
504 let resolved_body = if context.is_empty() {
505 body.clone()
506 } else {
507 faucet_core::util::substitute_context(body, context)
508 };
509 req = req
510 .header("Content-Type", "text/xml; charset=utf-8")
511 .body(resolved_body);
512 }
513
514 faucet_core::execute_with_policy(&self.retry_policy, None, || {
518 let attempt = req.try_clone();
519 async move {
520 let req = attempt.ok_or_else(|| {
521 FaucetError::Source("xml: request is not cloneable for retry".into())
522 })?;
523 let resp = req.send().await.map_err(FaucetError::Http)?;
524 let resp = util::check_http_response(resp, DEFAULT_ERROR_BODY_MAX_LEN).await?;
525 resp.text().await.map_err(FaucetError::Http)
526 }
527 })
528 .await
529 }
530}
531
532#[async_trait]
533impl faucet_core::Source for XmlStream {
534 async fn fetch_with_context(
535 &self,
536 context: &std::collections::HashMap<String, serde_json::Value>,
537 ) -> Result<Vec<Value>, FaucetError> {
538 self.fetch_all_with_context(context).await
539 }
540
541 fn stream_pages<'a>(
564 &'a self,
565 context: &'a HashMap<String, Value>,
566 _batch_size: usize,
567 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
568 let batch_size = self.config.batch_size;
569 let owned_context = context.clone();
570
571 Box::pin(async_stream::try_stream! {
572 self.config.validate()?;
573
574 if !self.config.decode.is_empty()
578 || matches!(self.config.pagination, Some(XmlPagination::BodyCursor { .. }))
579 {
580 let records = self.fetch_all_with_context(&owned_context).await?;
581 let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
582 for c in records.chunks(chunk) {
583 yield StreamPage { records: c.to_vec(), bookmark: None };
584 }
585 return;
586 }
587
588 let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
589 let initial_capacity = if batch_size == 0 { 1024 } else { batch_size };
590 let mut buffer: Vec<Value> = Vec::with_capacity(initial_capacity);
591 let mut total = 0usize;
592 let mut pages_fetched = 0usize;
593 let mut offset = 0usize;
594 let mut page_number = None;
595 let mut prev_fingerprint: Option<u64> = None;
596 let mut fault_logged = false;
597
598 if let Some(XmlPagination::PageNumber { start_page, .. }) =
599 &self.config.pagination
600 {
601 page_number = Some(*start_page);
602 }
603
604 loop {
605 if let Some(max) = self.config.max_pages
606 && pages_fetched >= max
607 {
608 tracing::warn!("max pages ({max}) reached");
609 break;
610 }
611
612 let mut params = self.config.query_params.clone();
613 self.apply_pagination_params(&mut params, page_number, offset);
614
615 let xml_text = self.execute_request(¶ms, &owned_context, None).await?;
616
617 let mut page_records: Vec<Value> = Vec::new();
630 if self.config.soap.is_some() {
631 page_records = self.extract_records_eager(&xml_text, &mut fault_logged)?;
632 } else {
633 convert::stream_extract(
634 &xml_text,
635 self.config.records_element_path.as_deref(),
636 |rec| page_records.push(rec),
637 )?;
638 }
639
640 let record_count = page_records.len();
641 let fingerprint = page_fingerprint(&page_records);
642 pages_fetched += 1;
643
644 if record_count > 0 && prev_fingerprint == Some(fingerprint) {
650 tracing::warn!(
651 "XML pagination returned an identical page; stopping to avoid an infinite loop"
652 );
653 break;
654 }
655 prev_fingerprint = Some(fingerprint);
656
657 for rec in page_records.drain(..) {
658 buffer.push(rec);
659 if buffer.len() >= chunk {
660 let flush = std::mem::replace(&mut buffer, Vec::with_capacity(initial_capacity));
661 total += flush.len();
662 yield StreamPage { records: flush, bookmark: None };
663 }
664 }
665
666 match &self.config.pagination {
669 Some(XmlPagination::PageNumber { page_size, .. }) => {
670 if record_count == 0 {
671 break;
672 }
673 if let Some(size) = page_size
674 && record_count < *size
675 {
676 break;
677 }
678 page_number = page_number.map(|p| p + 1);
679 }
680 Some(XmlPagination::Offset { limit, .. }) => {
681 if record_count < *limit {
682 break;
683 }
684 offset += record_count;
685 }
686 Some(XmlPagination::BodyCursor { .. }) => break,
689 None => break,
690 }
691 }
692
693 if !buffer.is_empty() {
694 total += buffer.len();
695 yield StreamPage { records: buffer, bookmark: None };
696 }
697
698 tracing::info!(
699 records = total,
700 pages = pages_fetched,
701 batch_size,
702 "XML source stream complete",
703 );
704 })
705 }
706
707 fn connector_name(&self) -> &'static str {
708 "xml"
709 }
710
711 fn config_schema(&self) -> serde_json::Value {
712 serde_json::to_value(faucet_core::schema_for!(XmlStreamConfig))
713 .expect("schema serialization")
714 }
715
716 fn dataset_uri(&self) -> String {
717 format!(
718 "{}{}",
719 faucet_core::redact_uri_credentials(&self.config.base_url),
720 self.config.path
721 )
722 }
723}
724
725#[cfg(test)]
726mod tests {
727 use super::*;
728 use crate::config::{SoapConfig, SoapVersion};
729 use faucet_core::Source;
730
731 fn soap_response(records: &str) -> String {
732 format!(
733 "<Envelope xmlns=\"http://schemas.xmlsoap.org/soap/envelope/\"><Body>\
734 <GetUsersResponse><Users>{records}</Users></GetUsersResponse></Body></Envelope>"
735 )
736 }
737
738 #[test]
739 fn effective_path_prepends_envelope_body_by_default() {
740 let source = XmlStream::new(
741 XmlStreamConfig::new("https://s", "/svc")
742 .method(reqwest::Method::POST)
743 .records_element_path("GetUsersResponse.Users.User")
744 .with_soap(SoapConfig {
745 body_inner: Some("<Op/>".into()),
746 ..Default::default()
747 }),
748 );
749 assert_eq!(
750 source.effective_records_path().as_deref(),
751 Some("Envelope.Body.GetUsersResponse.Users.User")
752 );
753 }
754
755 #[test]
756 fn effective_path_absolute_override_when_not_relative() {
757 let source = XmlStream::new(
758 XmlStreamConfig::new("https://s", "/svc")
759 .method(reqwest::Method::POST)
760 .records_element_path("Envelope.Body.GetUsersResponse.Users.User")
761 .with_soap(SoapConfig {
762 body_inner: Some("<Op/>".into()),
763 path_relative_to_body: false,
764 ..Default::default()
765 }),
766 );
767 assert_eq!(
768 source.effective_records_path().as_deref(),
769 Some("Envelope.Body.GetUsersResponse.Users.User")
770 );
771 }
772
773 #[test]
774 fn effective_path_unchanged_without_soap() {
775 let source = XmlStream::new(
776 XmlStreamConfig::new("https://s", "/svc").records_element_path("root.item"),
777 );
778 assert_eq!(
779 source.effective_records_path().as_deref(),
780 Some("root.item")
781 );
782 }
783
784 #[test]
785 fn extract_records_eager_resolves_relative_soap_path() {
786 let source = XmlStream::new(
787 XmlStreamConfig::new("https://s", "/svc")
788 .method(reqwest::Method::POST)
789 .records_element_path("GetUsersResponse.Users.User")
790 .with_soap(SoapConfig {
791 body_inner: Some("<Op/>".into()),
792 ..Default::default()
793 }),
794 );
795 let xml = soap_response("<User><Name>Alice</Name></User><User><Name>Bob</Name></User>");
796 let mut logged = false;
797 let records = source.extract_records_eager(&xml, &mut logged).unwrap();
798 assert_eq!(records.len(), 2);
799 assert_eq!(records[0]["Name"], "Alice");
800 assert_eq!(records[1]["Name"], "Bob");
801 }
802
803 #[test]
804 fn extract_records_eager_fault_as_error_raises_source_error() {
805 let source = XmlStream::new(
806 XmlStreamConfig::new("https://s", "/svc")
807 .method(reqwest::Method::POST)
808 .records_element_path("GetUsersResponse.Users.User")
809 .with_soap(SoapConfig {
810 body_inner: Some("<Op/>".into()),
811 ..Default::default()
812 }),
813 );
814 let xml = r#"<Envelope xmlns="http://schemas.xmlsoap.org/soap/envelope/"><Body>
815 <Fault><faultcode>Server</faultcode><faultstring>kaboom</faultstring></Fault>
816 </Body></Envelope>"#;
817 let mut logged = false;
818 let err = source.extract_records_eager(xml, &mut logged).unwrap_err();
819 assert!(
820 matches!(&err, FaucetError::Source(m) if m.contains("SOAP fault") && m.contains("kaboom")),
821 "got {err:?}"
822 );
823 }
824
825 #[test]
826 fn extract_records_eager_fault_not_error_yields_zero_records() {
827 let source = XmlStream::new(
828 XmlStreamConfig::new("https://s", "/svc")
829 .method(reqwest::Method::POST)
830 .records_element_path("GetUsersResponse.Users.User")
831 .with_soap(SoapConfig {
832 body_inner: Some("<Op/>".into()),
833 fault_as_error: false,
834 ..Default::default()
835 }),
836 );
837 let xml = r#"<Envelope xmlns="http://schemas.xmlsoap.org/soap/envelope/"><Body>
838 <Fault><faultstring>ignored</faultstring></Fault>
839 </Body></Envelope>"#;
840 let mut logged = false;
841 let records = source.extract_records_eager(xml, &mut logged).unwrap();
842 assert!(records.is_empty());
843 assert!(logged, "fault should be recorded as logged");
844 }
845
846 #[test]
847 fn extract_records_eager_non_soap_matches_legacy_eager_path() {
848 let source = XmlStream::new(
851 XmlStreamConfig::new("https://s", "/svc").records_element_path("root.item"),
852 );
853 let xml = "<root><item><id>1</id></item><item><id>2</id></item></root>";
854 let mut logged = false;
855 let records = source.extract_records_eager(xml, &mut logged).unwrap();
856 let legacy = convert::extract_at_path(&convert::xml_to_json(xml).unwrap(), "root.item");
857 assert_eq!(records, legacy);
858 assert_eq!(records.len(), 2);
859 }
860
861 #[tokio::test]
862 async fn fetch_all_rejects_invalid_soap_config() {
863 let source = XmlStream::new(
866 XmlStreamConfig::new("https://s", "/svc").with_soap(SoapConfig::default()),
867 );
868 let err = source.fetch_all().await.unwrap_err();
869 assert!(matches!(&err, FaucetError::Config(_)), "got {err:?}");
870 }
871
872 #[test]
873 fn soap12_content_type_used_for_envelope() {
874 let soap = SoapConfig {
876 version: SoapVersion::Soap12,
877 action: Some("urn:Op".into()),
878 ..Default::default()
879 };
880 assert!(soap.content_type().starts_with("application/soap+xml"));
881 }
882
883 #[test]
884 fn dataset_uri_combines_base_and_path() {
885 let source = XmlStream::new(XmlStreamConfig::new(
886 "https://soap.example.com",
887 "/api/v1/service",
888 ));
889 assert_eq!(
890 source.dataset_uri(),
891 "https://soap.example.com/api/v1/service"
892 );
893 }
894
895 #[test]
896 fn dataset_uri_redacts_credentials() {
897 let source = XmlStream::new(XmlStreamConfig::new(
898 "https://user:pass@soap.example.com",
899 "/svc",
900 ));
901 assert_eq!(source.dataset_uri(), "https://soap.example.com/svc");
902 }
903
904 #[test]
905 fn default_retry_policy_reproduces_legacy_constants() {
906 let source = XmlStream::new(XmlStreamConfig::new("https://soap.example.com", "/svc"));
907 assert_eq!(source.retry_policy.max_attempts, RETRY_MAX_ATTEMPTS + 1);
908 assert_eq!(source.retry_policy.base, RETRY_BASE_BACKOFF);
909 }
910
911 #[test]
912 fn with_retry_policy_overrides_the_default() {
913 let policy = faucet_core::RetryPolicy {
914 max_attempts: 9,
915 base: Duration::from_secs(7),
916 ..faucet_core::RetryPolicy::default()
917 };
918 let source = XmlStream::new(XmlStreamConfig::new("https://soap.example.com", "/svc"))
919 .with_retry_policy(policy);
920 assert_eq!(source.retry_policy.max_attempts, 9);
921 assert_eq!(source.retry_policy.base, Duration::from_secs(7));
922 }
923}
924
925#[cfg(all(test, feature = "mtls"))]
927mod mtls_tests {
928 use super::*;
929 use faucet_core::TlsClientConfig;
930
931 const CERT: &str = include_str!("../tests/fixtures/mtls/cert.pem");
932 const KEY: &str = include_str!("../tests/fixtures/mtls/key.pem");
933
934 fn pem() -> TlsClientConfig {
935 TlsClientConfig {
936 client_cert: Some(CERT.to_string()),
937 client_key: Some(KEY.to_string()),
938 ..Default::default()
939 }
940 }
941
942 #[test]
943 fn pem_identity_builds() {
944 let cfg = XmlStreamConfig::new("https://x.test", "/y").tls(pem());
945 assert!(XmlStream::try_new(cfg).is_ok());
946 }
947
948 #[test]
949 fn min_version_branches_are_exercised() {
950 let mut tls = pem();
951 tls.min_version = Some("1.2".into());
952 assert!(XmlStream::try_new(XmlStreamConfig::new("https://x.test", "/y").tls(tls)).is_ok());
953 let mut tls = pem();
956 tls.min_version = Some("1.3".into());
957 let _ = XmlStream::try_new(XmlStreamConfig::new("https://x.test", "/y").tls(tls));
958 }
959
960 #[test]
961 fn pkcs12_identity_builds() {
962 let p12 = concat!(
963 env!("CARGO_MANIFEST_DIR"),
964 "/tests/fixtures/mtls/identity.p12"
965 );
966 let tls = TlsClientConfig {
967 client_identity_pkcs12: Some(p12.to_string()),
968 pkcs12_password: Some("changeit".into()),
969 ..Default::default()
970 };
971 let cfg = XmlStreamConfig::new("https://x.test", "/y").tls(tls);
972 assert!(XmlStream::try_new(cfg).is_ok());
973 }
974
975 #[test]
976 fn invalid_pem_errors_without_leaking_key() {
977 let tls = TlsClientConfig {
978 client_cert: Some("-----BEGIN CERTIFICATE-----\nbad\n-----END CERTIFICATE-----".into()),
979 client_key: Some("SUPERSECRETKEY".into()),
980 ..Default::default()
981 };
982 let cfg = XmlStreamConfig::new("https://x.test", "/y").tls(tls);
983 let err = XmlStream::try_new(cfg)
984 .map(|_| ())
985 .expect_err("bad PEM must error");
986 assert!(!err.to_string().contains("SUPERSECRETKEY"));
987 }
988
989 #[test]
990 fn invalid_tls_shape_errors() {
991 let mut tls = pem();
993 tls.client_identity_pkcs12 = Some("/x.p12".into());
994 let cfg = XmlStreamConfig::new("https://x.test", "/y").tls(tls);
995 assert!(XmlStream::try_new(cfg).is_err());
996 }
997
998 #[test]
999 fn missing_pkcs12_file_errors() {
1000 let tls = TlsClientConfig {
1001 client_identity_pkcs12: Some("/no/such.p12".into()),
1002 pkcs12_password: Some("x".into()),
1003 ..Default::default()
1004 };
1005 let cfg = XmlStreamConfig::new("https://x.test", "/y").tls(tls);
1006 assert!(XmlStream::try_new(cfg).is_err());
1007 }
1008
1009 #[test]
1010 fn config_validate_checks_tls() {
1011 assert!(
1012 XmlStreamConfig::new("https://x.test", "/y")
1013 .tls(pem())
1014 .validate()
1015 .is_ok()
1016 );
1017 let mut bad = pem();
1018 bad.client_identity_pkcs12 = Some("/x.p12".into());
1019 assert!(
1020 XmlStreamConfig::new("https://x.test", "/y")
1021 .tls(bad)
1022 .validate()
1023 .is_err()
1024 );
1025 }
1026}