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, CredentialPlacement, FaucetError, SharedAuthProvider};
8use faucet_core::{Stream, StreamPage};
9use reqwest::Client;
10use serde_json::Value;
11use std::collections::{BTreeMap, 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
31fn substitute_captured(s: &str, captured: &BTreeMap<String, String>) -> String {
37 if captured.is_empty() || !s.contains("${") {
38 return s.to_string();
39 }
40 let mut out = s.to_string();
41 for (k, v) in captured {
42 out = out.replace(&format!("${{{k}}}"), v);
43 }
44 out
45}
46
47const RETRY_MAX_ATTEMPTS: u32 = 3;
49const RETRY_BASE_BACKOFF: Duration = Duration::from_millis(500);
51
52pub struct XmlStream {
54 config: XmlStreamConfig,
55 client: Client,
56 auth_provider: Option<SharedAuthProvider>,
61 retry_policy: faucet_core::RetryPolicy,
65}
66
67#[cfg(feature = "mtls")]
71fn apply_client_tls(
72 builder: reqwest::ClientBuilder,
73 tls: &faucet_core::TlsClientConfig,
74) -> Result<reqwest::ClientBuilder, FaucetError> {
75 let identity = build_identity(tls)?;
76 let mut builder = builder.identity(identity).use_native_tls();
77 if let Some(v) = &tls.min_version {
78 let version = if v == "1.3" {
80 reqwest::tls::Version::TLS_1_3
81 } else {
82 reqwest::tls::Version::TLS_1_2
83 };
84 builder = builder.min_tls_version(version);
85 }
86 Ok(builder)
87}
88
89#[cfg(not(feature = "mtls"))]
90fn apply_client_tls(
91 _builder: reqwest::ClientBuilder,
92 _tls: &faucet_core::TlsClientConfig,
93) -> Result<reqwest::ClientBuilder, FaucetError> {
94 Err(FaucetError::Config(
95 "a `tls:` (mutual-TLS) block is configured, but this build of \
96 faucet-source-xml lacks the `mtls` feature; rebuild with `--features mtls`"
97 .into(),
98 ))
99}
100
101#[cfg(feature = "mtls")]
104fn build_identity(tls: &faucet_core::TlsClientConfig) -> Result<reqwest::Identity, FaucetError> {
105 if let Some(p12_path) = &tls.client_identity_pkcs12 {
106 let der = std::fs::read(p12_path).map_err(|e| {
107 FaucetError::Config(format!(
108 "tls: could not read PKCS#12 file {p12_path:?}: {e}"
109 ))
110 })?;
111 let password = tls.pkcs12_password.as_deref().unwrap_or("");
112 reqwest::Identity::from_pkcs12_der(&der, password)
113 .map_err(|e| FaucetError::Config(format!("tls: invalid PKCS#12 identity: {e}")))
114 } else {
115 let cert = tls.client_cert.as_deref().unwrap_or_default();
116 let key = tls.client_key.as_deref().unwrap_or_default();
117 reqwest::Identity::from_pkcs8_pem(cert.as_bytes(), key.as_bytes())
118 .map_err(|e| FaucetError::Config(format!("tls: invalid PEM client identity: {e}")))
119 }
120}
121
122fn credential_to_auth(cred: Credential) -> XmlAuth {
125 match cred {
126 Credential::Bearer(token) => XmlAuth::Bearer { token },
127 Credential::Token(token) => XmlAuth::Custom {
128 headers: std::iter::once(("Authorization".to_string(), token)).collect(),
129 },
130 Credential::Basic { username, password } => XmlAuth::Basic { username, password },
131 Credential::Header { name, value } => XmlAuth::Custom {
132 headers: std::iter::once((name, value)).collect(),
133 },
134 }
135}
136
137impl XmlStream {
138 pub fn new(config: XmlStreamConfig) -> Self {
145 Self::try_new(config)
146 .expect("XmlStream::new: client build failed; use try_new() for fallible construction")
147 }
148
149 pub fn try_new(config: XmlStreamConfig) -> Result<Self, FaucetError> {
158 let mut builder = Client::builder();
159 if let Some(tls) = &config.tls {
160 tls.validate()?;
161 builder = apply_client_tls(builder, tls)?;
162 }
163 let client = builder
164 .build()
165 .map_err(|e| FaucetError::Config(format!("xml: failed to build HTTP client: {e}")))?;
166 Ok(Self {
167 config,
168 client,
169 auth_provider: None,
170 retry_policy: faucet_core::RetryPolicy {
174 max_attempts: RETRY_MAX_ATTEMPTS + 1,
175 backoff: faucet_core::BackoffKind::Exponential,
176 base: RETRY_BASE_BACKOFF,
177 max: Duration::from_secs(60),
178 jitter: true,
179 retry_on: faucet_core::RetryClassSet::default(),
180 },
181 })
182 }
183
184 pub fn with_retry_policy(mut self, policy: faucet_core::RetryPolicy) -> Self {
189 self.retry_policy = policy;
190 self
191 }
192
193 pub fn with_auth_provider(mut self, provider: SharedAuthProvider) -> Self {
200 self.auth_provider = Some(provider);
201 self
202 }
203
204 fn effective_records_path(&self) -> Option<String> {
212 match (&self.config.soap, &self.config.records_element_path) {
213 (Some(soap), Some(path)) if soap.path_relative_to_body => {
214 Some(format!("Envelope.Body.{path}"))
215 }
216 (_, path) => path.clone(),
217 }
218 }
219
220 fn extract_records_eager(
230 &self,
231 xml_text: &str,
232 fault_logged: &mut bool,
233 ) -> Result<Vec<Value>, FaucetError> {
234 let doc = convert::xml_to_json(xml_text)?;
235
236 if let Some(soap) = &self.config.soap
237 && let Some(message) = convert::detect_soap_fault(&doc)
238 {
239 if soap.fault_as_error {
240 return Err(FaucetError::Source(format!("SOAP fault: {message}")));
241 }
242 if !*fault_logged {
243 tracing::warn!(
244 fault = %message,
245 "SOAP fault in response; emitting zero records (fault_as_error=false)"
246 );
247 *fault_logged = true;
248 }
249 return Ok(Vec::new());
250 }
251
252 let records = match self.effective_records_path() {
253 Some(path) => convert::extract_at_path(&doc, &path),
254 None => vec![doc],
255 };
256 Ok(records)
257 }
258
259 pub async fn fetch_all(&self) -> Result<Vec<Value>, FaucetError> {
261 self.fetch_all_with_context(&HashMap::new()).await
262 }
263
264 async fn fetch_all_with_context(
266 &self,
267 context: &HashMap<String, serde_json::Value>,
268 ) -> Result<Vec<Value>, FaucetError> {
269 self.config.validate()?;
270
271 let mut all_records = Vec::new();
272 let mut pages_fetched = 0usize;
273 let mut offset = 0usize;
274 let mut page_number = None;
275 let mut prev_fingerprint: Option<u64> = None;
276 let mut fault_logged = false;
277 let mut body_override: Option<String> = None;
281 let mut prev_token: Option<String> = None;
282
283 if let Some(XmlPagination::PageNumber { start_page, .. }) = &self.config.pagination {
285 page_number = Some(*start_page);
286 }
287
288 loop {
289 if let Some(max) = self.config.max_pages
290 && pages_fetched >= max
291 {
292 tracing::warn!("max pages ({max}) reached");
293 break;
294 }
295
296 let mut params = self.config.query_params.clone();
297 self.apply_pagination_params(&mut params, page_number, offset);
298
299 let xml_text = self
300 .execute_request(¶ms, context, body_override.as_deref())
301 .await?;
302 let records = if self.config.decode.is_empty() {
306 self.extract_records_eager(&xml_text, &mut fault_logged)?
307 } else {
308 crate::decode::run_decode(xml_text.as_bytes(), &self.config.decode).await?
309 };
310
311 let record_count = records.len();
312 let fingerprint = page_fingerprint(&records);
313 pages_fetched += 1;
314
315 if record_count > 0 && prev_fingerprint == Some(fingerprint) {
321 tracing::warn!(
322 "XML pagination returned an identical page; stopping to avoid an infinite loop"
323 );
324 break;
325 }
326 prev_fingerprint = Some(fingerprint);
327 all_records.extend(records);
328
329 match &self.config.pagination {
331 Some(XmlPagination::PageNumber { page_size, .. }) => {
332 if record_count == 0 {
333 break;
334 }
335 if let Some(size) = page_size
337 && record_count < *size
338 {
339 break;
340 }
341 page_number = page_number.map(|p| p + 1);
342 }
343 Some(XmlPagination::Offset { limit, .. }) => {
344 if record_count < *limit {
345 break;
346 }
347 offset += record_count;
348 }
349 Some(XmlPagination::BodyCursor {
350 next_token_path,
351 next_body,
352 }) => {
353 match crate::decode::xml_extract_text(xml_text.as_bytes(), next_token_path) {
355 Some(t)
357 if !t.trim().is_empty()
358 && prev_token.as_deref() != Some(t.as_str()) =>
359 {
360 body_override = Some(next_body.replace("${next_token}", &t));
361 prev_token = Some(t);
362 }
363 _ => break,
364 }
365 }
366 None => break,
367 }
368 }
369
370 tracing::info!(
371 records = all_records.len(),
372 pages = pages_fetched,
373 "XML fetch complete"
374 );
375 Ok(all_records)
376 }
377
378 fn apply_pagination_params(
379 &self,
380 params: &mut HashMap<String, String>,
381 page_number: Option<usize>,
382 offset: usize,
383 ) {
384 match &self.config.pagination {
385 Some(XmlPagination::PageNumber {
386 param_name,
387 page_size,
388 page_size_param,
389 ..
390 }) => {
391 if let Some(page) = page_number {
392 params.insert(param_name.clone(), page.to_string());
393 }
394 if let (Some(size), Some(param)) = (page_size, page_size_param) {
395 params.insert(param.clone(), size.to_string());
396 }
397 }
398 Some(XmlPagination::Offset {
399 offset_param,
400 limit_param,
401 limit,
402 }) => {
403 params.insert(offset_param.clone(), offset.to_string());
404 params.insert(limit_param.clone(), limit.to_string());
405 }
406 Some(XmlPagination::BodyCursor { .. }) => {}
409 None => {}
410 }
411 }
412
413 async fn execute_request(
414 &self,
415 params: &HashMap<String, String>,
416 context: &HashMap<String, serde_json::Value>,
417 body_override: Option<&str>,
418 ) -> Result<String, FaucetError> {
419 let path = if context.is_empty() {
420 self.config.path.clone()
421 } else {
422 faucet_core::util::substitute_context(&self.config.path, context)
423 };
424
425 let mut base_url = self.config.base_url.clone();
431 let mut ra_headers: Vec<(String, String)> = Vec::new();
432 let mut ra_query: Vec<(String, String)> = Vec::new();
433 let mut ra_cookies: Vec<(String, String)> = Vec::new();
434 let mut captured: BTreeMap<String, String> = BTreeMap::new();
435 let mut used_request_auth = false;
436 if let Some(provider) = &self.auth_provider {
437 let q: BTreeMap<String, String> =
438 params.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
439 let ra = provider
440 .request_auth(self.config.method.as_str(), &base_url, &q)
441 .await?;
442 if !ra.is_empty() {
443 used_request_auth = true;
444 if let Some(b) = ra.base_url {
445 base_url = b;
446 }
447 for p in ra.placements {
448 match p {
449 CredentialPlacement::Header { name, value } => {
450 ra_headers.push((name, value))
451 }
452 CredentialPlacement::Query { name, value } => ra_query.push((name, value)),
453 CredentialPlacement::Cookie { name, value } => {
454 ra_cookies.push((name, value))
455 }
456 _ => {}
459 }
460 }
461 captured = ra.captured;
462 }
463 }
464
465 let url = format!("{}/{}", base_url, path.trim_start_matches('/'));
466
467 let mut resolved_params: HashMap<String, String> = params
470 .iter()
471 .map(|(k, v)| {
472 let v = if context.is_empty() {
473 v.clone()
474 } else {
475 faucet_core::util::substitute_context(v, context)
476 };
477 (k.clone(), substitute_captured(&v, &captured))
478 })
479 .collect();
480 for (k, v) in ra_query {
481 resolved_params.insert(k, v);
482 }
483
484 let mut header_map = reqwest::header::HeaderMap::new();
487 for (name, value) in self.config.headers.iter() {
488 let sv = substitute_captured(value.to_str().unwrap_or_default(), &captured);
489 match reqwest::header::HeaderValue::from_str(&sv) {
490 Ok(hv) => header_map.insert(name.clone(), hv),
491 Err(_) => header_map.insert(name.clone(), value.clone()),
492 };
493 }
494 for (name, value) in &ra_headers {
495 if let (Ok(n), Ok(v)) = (
496 reqwest::header::HeaderName::from_bytes(name.as_bytes()),
497 reqwest::header::HeaderValue::from_str(value),
498 ) {
499 header_map.insert(n, v);
500 }
501 }
502 if !ra_cookies.is_empty() {
503 let cookie = ra_cookies
504 .iter()
505 .map(|(n, v)| format!("{n}={v}"))
506 .collect::<Vec<_>>()
507 .join("; ");
508 if let Ok(v) = reqwest::header::HeaderValue::from_str(&cookie) {
509 header_map.insert(reqwest::header::COOKIE, v);
510 }
511 }
512
513 let mut req = self
514 .client
515 .request(self.config.method.clone(), &url)
516 .headers(header_map)
517 .query(&resolved_params);
518
519 if !used_request_auth {
522 let effective_auth: XmlAuth = if let Some(provider) = &self.auth_provider {
523 credential_to_auth(provider.credential().await?)
524 } else {
525 match &self.config.auth {
526 AuthSpec::Inline(a) => a.clone(),
527 AuthSpec::Reference(r) => {
528 return Err(FaucetError::Auth(format!(
529 "auth references provider '{}' but no provider was supplied; \
530 set one via the CLI `auth:` catalog or `with_auth_provider`",
531 r.name
532 )));
533 }
534 }
535 };
536
537 match &effective_auth {
538 XmlAuth::None => {}
539 XmlAuth::Bearer { token } => {
540 req = req.bearer_auth(token);
541 }
542 XmlAuth::Basic { username, password } => {
543 req = req.basic_auth(username, Some(password));
544 }
545 XmlAuth::Custom { headers } => {
546 let mut hm = reqwest::header::HeaderMap::new();
547 for (name, value) in headers {
548 let n = reqwest::header::HeaderName::from_bytes(name.as_bytes()).map_err(
549 |e| {
550 FaucetError::Auth(format!(
551 "invalid custom header name {name:?}: {e}"
552 ))
553 },
554 )?;
555 let v = reqwest::header::HeaderValue::from_str(value).map_err(|e| {
556 FaucetError::Auth(format!(
557 "invalid custom header value for {name:?}: {e}"
558 ))
559 })?;
560 hm.insert(n, v);
561 }
562 req = req.headers(hm);
563 }
564 }
565 }
566
567 if let Some(ob) = body_override {
575 let resolved = if context.is_empty() {
579 ob.to_string()
580 } else {
581 faucet_core::util::substitute_context(ob, context)
582 };
583 let resolved = substitute_captured(&resolved, &captured);
584 req = req
585 .header("Content-Type", "text/xml; charset=utf-8")
586 .body(resolved);
587 } else if let Some(soap) = &self.config.soap {
588 let inner = soap.body_inner.as_deref().unwrap_or("");
589 let resolved_inner = if context.is_empty() {
590 inner.to_string()
591 } else {
592 faucet_core::util::substitute_context(inner, context)
593 };
594 let resolved_inner = substitute_captured(&resolved_inner, &captured);
595 let envelope = soap.build_envelope(&resolved_inner);
596 req = req
597 .header("Content-Type", soap.content_type())
598 .body(envelope);
599 if let Some(action) = soap.soap_action_header() {
600 req = req.header("SOAPAction", action);
601 }
602 } else if let Some(body) = &self.config.body {
603 let resolved_body = if context.is_empty() {
604 body.clone()
605 } else {
606 faucet_core::util::substitute_context(body, context)
607 };
608 let resolved_body = substitute_captured(&resolved_body, &captured);
609 req = req
610 .header("Content-Type", "text/xml; charset=utf-8")
611 .body(resolved_body);
612 }
613
614 faucet_core::execute_with_policy(&self.retry_policy, None, || {
618 let attempt = req.try_clone();
619 async move {
620 let req = attempt.ok_or_else(|| {
621 FaucetError::Source("xml: request is not cloneable for retry".into())
622 })?;
623 let resp = req.send().await.map_err(FaucetError::Http)?;
624 let resp = util::check_http_response(resp, DEFAULT_ERROR_BODY_MAX_LEN).await?;
625 resp.text().await.map_err(FaucetError::Http)
626 }
627 })
628 .await
629 }
630}
631
632#[async_trait]
633impl faucet_core::Source for XmlStream {
634 async fn fetch_with_context(
635 &self,
636 context: &std::collections::HashMap<String, serde_json::Value>,
637 ) -> Result<Vec<Value>, FaucetError> {
638 self.fetch_all_with_context(context).await
639 }
640
641 fn stream_pages<'a>(
664 &'a self,
665 context: &'a HashMap<String, Value>,
666 _batch_size: usize,
667 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
668 let batch_size = self.config.batch_size;
669 let owned_context = context.clone();
670
671 Box::pin(async_stream::try_stream! {
672 self.config.validate()?;
673
674 if !self.config.decode.is_empty()
678 || matches!(self.config.pagination, Some(XmlPagination::BodyCursor { .. }))
679 {
680 let records = self.fetch_all_with_context(&owned_context).await?;
681 let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
682 for c in records.chunks(chunk) {
683 yield StreamPage { records: c.to_vec(), bookmark: None };
684 }
685 return;
686 }
687
688 let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
689 let initial_capacity = if batch_size == 0 { 1024 } else { batch_size };
690 let mut buffer: Vec<Value> = Vec::with_capacity(initial_capacity);
691 let mut total = 0usize;
692 let mut pages_fetched = 0usize;
693 let mut offset = 0usize;
694 let mut page_number = None;
695 let mut prev_fingerprint: Option<u64> = None;
696 let mut fault_logged = false;
697
698 if let Some(XmlPagination::PageNumber { start_page, .. }) =
699 &self.config.pagination
700 {
701 page_number = Some(*start_page);
702 }
703
704 loop {
705 if let Some(max) = self.config.max_pages
706 && pages_fetched >= max
707 {
708 tracing::warn!("max pages ({max}) reached");
709 break;
710 }
711
712 let mut params = self.config.query_params.clone();
713 self.apply_pagination_params(&mut params, page_number, offset);
714
715 let xml_text = self.execute_request(¶ms, &owned_context, None).await?;
716
717 let mut page_records: Vec<Value> = Vec::new();
730 if self.config.soap.is_some() {
731 page_records = self.extract_records_eager(&xml_text, &mut fault_logged)?;
732 } else {
733 convert::stream_extract(
734 &xml_text,
735 self.config.records_element_path.as_deref(),
736 |rec| page_records.push(rec),
737 )?;
738 }
739
740 let record_count = page_records.len();
741 let fingerprint = page_fingerprint(&page_records);
742 pages_fetched += 1;
743
744 if record_count > 0 && prev_fingerprint == Some(fingerprint) {
750 tracing::warn!(
751 "XML pagination returned an identical page; stopping to avoid an infinite loop"
752 );
753 break;
754 }
755 prev_fingerprint = Some(fingerprint);
756
757 for rec in page_records.drain(..) {
758 buffer.push(rec);
759 if buffer.len() >= chunk {
760 let flush = std::mem::replace(&mut buffer, Vec::with_capacity(initial_capacity));
761 total += flush.len();
762 yield StreamPage { records: flush, bookmark: None };
763 }
764 }
765
766 match &self.config.pagination {
769 Some(XmlPagination::PageNumber { page_size, .. }) => {
770 if record_count == 0 {
771 break;
772 }
773 if let Some(size) = page_size
774 && record_count < *size
775 {
776 break;
777 }
778 page_number = page_number.map(|p| p + 1);
779 }
780 Some(XmlPagination::Offset { limit, .. }) => {
781 if record_count < *limit {
782 break;
783 }
784 offset += record_count;
785 }
786 Some(XmlPagination::BodyCursor { .. }) => break,
789 None => break,
790 }
791 }
792
793 if !buffer.is_empty() {
794 total += buffer.len();
795 yield StreamPage { records: buffer, bookmark: None };
796 }
797
798 tracing::info!(
799 records = total,
800 pages = pages_fetched,
801 batch_size,
802 "XML source stream complete",
803 );
804 })
805 }
806
807 fn connector_name(&self) -> &'static str {
808 "xml"
809 }
810
811 fn config_schema(&self) -> serde_json::Value {
812 serde_json::to_value(faucet_core::schema_for!(XmlStreamConfig))
813 .expect("schema serialization")
814 }
815
816 fn dataset_uri(&self) -> String {
817 format!(
818 "{}{}",
819 faucet_core::redact_uri_credentials(&self.config.base_url),
820 self.config.path
821 )
822 }
823}
824
825#[cfg(test)]
826mod tests {
827 use super::*;
828 use crate::config::{SoapConfig, SoapVersion};
829 use faucet_core::Source;
830
831 fn soap_response(records: &str) -> String {
832 format!(
833 "<Envelope xmlns=\"http://schemas.xmlsoap.org/soap/envelope/\"><Body>\
834 <GetUsersResponse><Users>{records}</Users></GetUsersResponse></Body></Envelope>"
835 )
836 }
837
838 #[test]
839 fn effective_path_prepends_envelope_body_by_default() {
840 let source = XmlStream::new(
841 XmlStreamConfig::new("https://s", "/svc")
842 .method(reqwest::Method::POST)
843 .records_element_path("GetUsersResponse.Users.User")
844 .with_soap(SoapConfig {
845 body_inner: Some("<Op/>".into()),
846 ..Default::default()
847 }),
848 );
849 assert_eq!(
850 source.effective_records_path().as_deref(),
851 Some("Envelope.Body.GetUsersResponse.Users.User")
852 );
853 }
854
855 #[test]
856 fn effective_path_absolute_override_when_not_relative() {
857 let source = XmlStream::new(
858 XmlStreamConfig::new("https://s", "/svc")
859 .method(reqwest::Method::POST)
860 .records_element_path("Envelope.Body.GetUsersResponse.Users.User")
861 .with_soap(SoapConfig {
862 body_inner: Some("<Op/>".into()),
863 path_relative_to_body: false,
864 ..Default::default()
865 }),
866 );
867 assert_eq!(
868 source.effective_records_path().as_deref(),
869 Some("Envelope.Body.GetUsersResponse.Users.User")
870 );
871 }
872
873 #[test]
874 fn effective_path_unchanged_without_soap() {
875 let source = XmlStream::new(
876 XmlStreamConfig::new("https://s", "/svc").records_element_path("root.item"),
877 );
878 assert_eq!(
879 source.effective_records_path().as_deref(),
880 Some("root.item")
881 );
882 }
883
884 #[test]
885 fn extract_records_eager_resolves_relative_soap_path() {
886 let source = XmlStream::new(
887 XmlStreamConfig::new("https://s", "/svc")
888 .method(reqwest::Method::POST)
889 .records_element_path("GetUsersResponse.Users.User")
890 .with_soap(SoapConfig {
891 body_inner: Some("<Op/>".into()),
892 ..Default::default()
893 }),
894 );
895 let xml = soap_response("<User><Name>Alice</Name></User><User><Name>Bob</Name></User>");
896 let mut logged = false;
897 let records = source.extract_records_eager(&xml, &mut logged).unwrap();
898 assert_eq!(records.len(), 2);
899 assert_eq!(records[0]["Name"], "Alice");
900 assert_eq!(records[1]["Name"], "Bob");
901 }
902
903 #[test]
904 fn extract_records_eager_fault_as_error_raises_source_error() {
905 let source = XmlStream::new(
906 XmlStreamConfig::new("https://s", "/svc")
907 .method(reqwest::Method::POST)
908 .records_element_path("GetUsersResponse.Users.User")
909 .with_soap(SoapConfig {
910 body_inner: Some("<Op/>".into()),
911 ..Default::default()
912 }),
913 );
914 let xml = r#"<Envelope xmlns="http://schemas.xmlsoap.org/soap/envelope/"><Body>
915 <Fault><faultcode>Server</faultcode><faultstring>kaboom</faultstring></Fault>
916 </Body></Envelope>"#;
917 let mut logged = false;
918 let err = source.extract_records_eager(xml, &mut logged).unwrap_err();
919 assert!(
920 matches!(&err, FaucetError::Source(m) if m.contains("SOAP fault") && m.contains("kaboom")),
921 "got {err:?}"
922 );
923 }
924
925 #[test]
926 fn extract_records_eager_fault_not_error_yields_zero_records() {
927 let source = XmlStream::new(
928 XmlStreamConfig::new("https://s", "/svc")
929 .method(reqwest::Method::POST)
930 .records_element_path("GetUsersResponse.Users.User")
931 .with_soap(SoapConfig {
932 body_inner: Some("<Op/>".into()),
933 fault_as_error: false,
934 ..Default::default()
935 }),
936 );
937 let xml = r#"<Envelope xmlns="http://schemas.xmlsoap.org/soap/envelope/"><Body>
938 <Fault><faultstring>ignored</faultstring></Fault>
939 </Body></Envelope>"#;
940 let mut logged = false;
941 let records = source.extract_records_eager(xml, &mut logged).unwrap();
942 assert!(records.is_empty());
943 assert!(logged, "fault should be recorded as logged");
944 }
945
946 #[test]
947 fn extract_records_eager_non_soap_matches_legacy_eager_path() {
948 let source = XmlStream::new(
951 XmlStreamConfig::new("https://s", "/svc").records_element_path("root.item"),
952 );
953 let xml = "<root><item><id>1</id></item><item><id>2</id></item></root>";
954 let mut logged = false;
955 let records = source.extract_records_eager(xml, &mut logged).unwrap();
956 let legacy = convert::extract_at_path(&convert::xml_to_json(xml).unwrap(), "root.item");
957 assert_eq!(records, legacy);
958 assert_eq!(records.len(), 2);
959 }
960
961 #[tokio::test]
962 async fn fetch_all_rejects_invalid_soap_config() {
963 let source = XmlStream::new(
966 XmlStreamConfig::new("https://s", "/svc").with_soap(SoapConfig::default()),
967 );
968 let err = source.fetch_all().await.unwrap_err();
969 assert!(matches!(&err, FaucetError::Config(_)), "got {err:?}");
970 }
971
972 #[test]
973 fn soap12_content_type_used_for_envelope() {
974 let soap = SoapConfig {
976 version: SoapVersion::Soap12,
977 action: Some("urn:Op".into()),
978 ..Default::default()
979 };
980 assert!(soap.content_type().starts_with("application/soap+xml"));
981 }
982
983 #[test]
984 fn dataset_uri_combines_base_and_path() {
985 let source = XmlStream::new(XmlStreamConfig::new(
986 "https://soap.example.com",
987 "/api/v1/service",
988 ));
989 assert_eq!(
990 source.dataset_uri(),
991 "https://soap.example.com/api/v1/service"
992 );
993 }
994
995 #[test]
996 fn dataset_uri_redacts_credentials() {
997 let source = XmlStream::new(XmlStreamConfig::new(
998 "https://user:pass@soap.example.com",
999 "/svc",
1000 ));
1001 assert_eq!(source.dataset_uri(), "https://soap.example.com/svc");
1002 }
1003
1004 #[test]
1005 fn default_retry_policy_reproduces_legacy_constants() {
1006 let source = XmlStream::new(XmlStreamConfig::new("https://soap.example.com", "/svc"));
1007 assert_eq!(source.retry_policy.max_attempts, RETRY_MAX_ATTEMPTS + 1);
1008 assert_eq!(source.retry_policy.base, RETRY_BASE_BACKOFF);
1009 }
1010
1011 #[test]
1012 fn with_retry_policy_overrides_the_default() {
1013 let policy = faucet_core::RetryPolicy {
1014 max_attempts: 9,
1015 base: Duration::from_secs(7),
1016 ..faucet_core::RetryPolicy::default()
1017 };
1018 let source = XmlStream::new(XmlStreamConfig::new("https://soap.example.com", "/svc"))
1019 .with_retry_policy(policy);
1020 assert_eq!(source.retry_policy.max_attempts, 9);
1021 assert_eq!(source.retry_policy.base, Duration::from_secs(7));
1022 }
1023}
1024
1025#[cfg(all(test, feature = "mtls"))]
1027mod mtls_tests {
1028 use super::*;
1029 use faucet_core::TlsClientConfig;
1030
1031 const CERT: &str = include_str!("../tests/fixtures/mtls/cert.pem");
1032 const KEY: &str = include_str!("../tests/fixtures/mtls/key.pem");
1033
1034 fn pem() -> TlsClientConfig {
1035 TlsClientConfig {
1036 client_cert: Some(CERT.to_string()),
1037 client_key: Some(KEY.to_string()),
1038 ..Default::default()
1039 }
1040 }
1041
1042 #[test]
1043 fn pem_identity_builds() {
1044 let cfg = XmlStreamConfig::new("https://x.test", "/y").tls(pem());
1045 assert!(XmlStream::try_new(cfg).is_ok());
1046 }
1047
1048 #[test]
1049 fn min_version_branches_are_exercised() {
1050 let mut tls = pem();
1051 tls.min_version = Some("1.2".into());
1052 assert!(XmlStream::try_new(XmlStreamConfig::new("https://x.test", "/y").tls(tls)).is_ok());
1053 let mut tls = pem();
1056 tls.min_version = Some("1.3".into());
1057 let _ = XmlStream::try_new(XmlStreamConfig::new("https://x.test", "/y").tls(tls));
1058 }
1059
1060 #[test]
1061 fn pkcs12_identity_builds() {
1062 let p12 = concat!(
1063 env!("CARGO_MANIFEST_DIR"),
1064 "/tests/fixtures/mtls/identity.p12"
1065 );
1066 let tls = TlsClientConfig {
1067 client_identity_pkcs12: Some(p12.to_string()),
1068 pkcs12_password: Some("changeit".into()),
1069 ..Default::default()
1070 };
1071 let cfg = XmlStreamConfig::new("https://x.test", "/y").tls(tls);
1072 assert!(XmlStream::try_new(cfg).is_ok());
1073 }
1074
1075 #[test]
1076 fn invalid_pem_errors_without_leaking_key() {
1077 let tls = TlsClientConfig {
1078 client_cert: Some("-----BEGIN CERTIFICATE-----\nbad\n-----END CERTIFICATE-----".into()),
1079 client_key: Some("SUPERSECRETKEY".into()),
1080 ..Default::default()
1081 };
1082 let cfg = XmlStreamConfig::new("https://x.test", "/y").tls(tls);
1083 let err = XmlStream::try_new(cfg)
1084 .map(|_| ())
1085 .expect_err("bad PEM must error");
1086 assert!(!err.to_string().contains("SUPERSECRETKEY"));
1087 }
1088
1089 #[test]
1090 fn invalid_tls_shape_errors() {
1091 let mut tls = pem();
1093 tls.client_identity_pkcs12 = Some("/x.p12".into());
1094 let cfg = XmlStreamConfig::new("https://x.test", "/y").tls(tls);
1095 assert!(XmlStream::try_new(cfg).is_err());
1096 }
1097
1098 #[test]
1099 fn missing_pkcs12_file_errors() {
1100 let tls = TlsClientConfig {
1101 client_identity_pkcs12: Some("/no/such.p12".into()),
1102 pkcs12_password: Some("x".into()),
1103 ..Default::default()
1104 };
1105 let cfg = XmlStreamConfig::new("https://x.test", "/y").tls(tls);
1106 assert!(XmlStream::try_new(cfg).is_err());
1107 }
1108
1109 #[test]
1110 fn config_validate_checks_tls() {
1111 assert!(
1112 XmlStreamConfig::new("https://x.test", "/y")
1113 .tls(pem())
1114 .validate()
1115 .is_ok()
1116 );
1117 let mut bad = pem();
1118 bad.client_identity_pkcs12 = Some("/x.p12".into());
1119 assert!(
1120 XmlStreamConfig::new("https://x.test", "/y")
1121 .tls(bad)
1122 .validate()
1123 .is_err()
1124 );
1125 }
1126}