Skip to main content

soap_server/
fault.rs

1//! SOAP fault types and serialization for SOAP 1.1 and 1.2.
2use crate::xml_escape::escape_text;
3use thiserror::Error;
4
5#[derive(Debug, Clone, PartialEq)]
6pub enum FaultCode {
7    VersionMismatch,
8    MustUnderstand,
9    DataEncodingUnknown,
10    Sender,
11    Receiver,
12}
13
14impl FaultCode {
15    /// Returns the SOAP 1.2 fault code string (e.g. `"env:Sender"`, `"env:Receiver"`).
16    /// Use this when serializing SOAP 1.2 responses.
17    pub fn as_soap12_str(&self) -> &'static str {
18        match self {
19            FaultCode::VersionMismatch => "env:VersionMismatch",
20            FaultCode::MustUnderstand => "env:MustUnderstand",
21            FaultCode::DataEncodingUnknown => "env:DataEncodingUnknown",
22            FaultCode::Sender => "env:Sender",
23            FaultCode::Receiver => "env:Receiver",
24        }
25    }
26
27    /// Returns the SOAP 1.1 fault code string (e.g. `"env:Client"`, `"env:Server"`).
28    /// Use this when serializing SOAP 1.1 responses.
29    pub fn as_soap11_str(&self) -> &'static str {
30        match self {
31            FaultCode::VersionMismatch => "env:VersionMismatch",
32            FaultCode::MustUnderstand => "env:MustUnderstand",
33            // DataEncodingUnknown has no SOAP 1.1 equivalent — map to Server per Apache CXF
34            FaultCode::DataEncodingUnknown => "env:Server",
35            FaultCode::Sender => "env:Client",
36            FaultCode::Receiver => "env:Server",
37        }
38    }
39}
40
41#[derive(Debug, Clone, Error)]
42#[error("SOAP Fault: {code:?} — {reason}")]
43pub struct SoapFault {
44    pub code: FaultCode,
45    pub reason: String,
46    pub detail: Option<String>,
47    /// Raw, verbatim XML to emit as the fault detail child element (not escaped).
48    /// Takes precedence over `detail` when both are set.
49    pub detail_xml: Option<String>,
50}
51
52impl SoapFault {
53    pub fn new(code: FaultCode, reason: impl Into<String>, detail: Option<String>) -> Self {
54        Self {
55            code,
56            reason: reason.into(),
57            detail,
58            detail_xml: None,
59        }
60    }
61
62    /// Attach a raw XML child element as the fault detail. The provided XML is emitted
63    /// VERBATIM inside `<env:Detail>` (SOAP 1.2) / `<detail>` (SOAP 1.1) — it is NOT escaped,
64    /// so the caller MUST supply well-formed XML. Takes precedence over the text `detail`.
65    pub fn with_detail_xml(mut self, xml: impl Into<String>) -> Self {
66        self.detail_xml = Some(xml.into());
67        self
68    }
69
70    pub fn sender(reason: impl Into<String>) -> Self {
71        Self::new(FaultCode::Sender, reason, None)
72    }
73
74    pub fn receiver(reason: impl Into<String>) -> Self {
75        Self::new(FaultCode::Receiver, reason, None)
76    }
77
78    pub fn version_mismatch() -> Self {
79        Self::new(FaultCode::VersionMismatch, "SOAP version mismatch", None)
80    }
81
82    pub fn must_understand(header: &str) -> Self {
83        Self::new(
84            FaultCode::MustUnderstand,
85            format!("Header not understood: {header}"),
86            None,
87        )
88    }
89
90    pub fn action_not_supported(action: &str) -> Self {
91        Self::new(
92            FaultCode::Sender,
93            format!("Action not supported: {action}"),
94            None,
95        )
96    }
97
98    /// Serialize to a complete SOAP envelope. Version determines fault structure and code names.
99    /// SOAP 1.2 uses nested Code/Reason (existing to_xml_bytes). SOAP 1.1 uses flat
100    /// faultcode/faultstring per W3C SOAP 1.1 spec Section 4.4.
101    pub fn to_xml_bytes_versioned(
102        &self,
103        version: &crate::wsdl::definitions::SoapVersion,
104    ) -> Vec<u8> {
105        match version {
106            crate::wsdl::definitions::SoapVersion::Soap12 => self.to_xml_bytes(),
107            crate::wsdl::definitions::SoapVersion::Soap11 => self.to_xml_bytes_v11(),
108        }
109    }
110
111    fn to_xml_bytes_v11(&self) -> Vec<u8> {
112        let ns = "http://schemas.xmlsoap.org/soap/envelope/";
113        let faultcode = match &self.code {
114            FaultCode::Sender => "SOAP-ENV:Client",
115            FaultCode::Receiver => "SOAP-ENV:Server",
116            FaultCode::VersionMismatch => "SOAP-ENV:VersionMismatch",
117            FaultCode::MustUnderstand => "SOAP-ENV:MustUnderstand",
118            // DataEncodingUnknown has no SOAP 1.1 equivalent — map to Server per Apache CXF
119            FaultCode::DataEncodingUnknown => "SOAP-ENV:Server",
120        };
121        let reason = escape_text(&self.reason);
122        let detail_xml = if let Some(raw) = &self.detail_xml {
123            format!("<detail>{raw}</detail>")
124        } else {
125            match &self.detail {
126                Some(detail) => format!("<detail>{}</detail>", escape_text(detail)),
127                None => String::new(),
128            }
129        };
130        format!(
131            r#"<SOAP-ENV:Envelope xmlns:SOAP-ENV="{ns}"><SOAP-ENV:Body><SOAP-ENV:Fault><faultcode>{faultcode}</faultcode><faultstring>{reason}</faultstring>{detail_xml}</SOAP-ENV:Fault></SOAP-ENV:Body></SOAP-ENV:Envelope>"#
132        ).into_bytes()
133    }
134
135    /// Serialize to a complete SOAP 1.2 envelope XML string.
136    /// HTTP status is always 500 per W3C SOAP 1.2 spec Section 7.4.2 (FLT-03).
137    pub fn to_xml_bytes(&self) -> Vec<u8> {
138        let code = self.code.as_soap12_str();
139        let reason = escape_text(&self.reason);
140
141        // SOAP 1.2 env:Detail is element-only (xs:any children; no character data).
142        // The human-readable message is already in Reason/Text, so when only a
143        // plain-text `detail` is set we emit nothing rather than invalid chardata (F-3).
144        let detail_xml = if let Some(raw) = &self.detail_xml {
145            format!("<env:Detail>{raw}</env:Detail>")
146        } else {
147            String::new()
148        };
149
150        let xml = format!(
151            r#"<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope"><env:Body><env:Fault><env:Code><env:Value>{code}</env:Value></env:Code><env:Reason><env:Text xml:lang="en">{reason}</env:Text></env:Reason>{detail_xml}</env:Fault></env:Body></env:Envelope>"#
152        );
153
154        xml.into_bytes()
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn fault_code_sender_as_soap12_str() {
164        assert_eq!(FaultCode::Sender.as_soap12_str(), "env:Sender");
165    }
166
167    #[test]
168    fn fault_code_receiver_as_soap12_str() {
169        assert_eq!(FaultCode::Receiver.as_soap12_str(), "env:Receiver");
170    }
171
172    #[test]
173    fn fault_code_version_mismatch_as_soap12_str() {
174        assert_eq!(
175            FaultCode::VersionMismatch.as_soap12_str(),
176            "env:VersionMismatch"
177        );
178    }
179
180    #[test]
181    fn fault_code_must_understand_as_soap12_str() {
182        assert_eq!(
183            FaultCode::MustUnderstand.as_soap12_str(),
184            "env:MustUnderstand"
185        );
186    }
187
188    #[test]
189    fn fault_code_data_encoding_unknown_as_soap12_str() {
190        assert_eq!(
191            FaultCode::DataEncodingUnknown.as_soap12_str(),
192            "env:DataEncodingUnknown"
193        );
194    }
195
196    #[test]
197    fn fault_code_sender_as_soap11_str() {
198        assert_eq!(FaultCode::Sender.as_soap11_str(), "env:Client");
199    }
200
201    #[test]
202    fn fault_code_receiver_as_soap11_str() {
203        assert_eq!(FaultCode::Receiver.as_soap11_str(), "env:Server");
204    }
205
206    #[test]
207    fn fault_code_version_mismatch_as_soap11_str() {
208        assert_eq!(
209            FaultCode::VersionMismatch.as_soap11_str(),
210            "env:VersionMismatch"
211        );
212    }
213
214    #[test]
215    fn fault_code_must_understand_as_soap11_str() {
216        assert_eq!(
217            FaultCode::MustUnderstand.as_soap11_str(),
218            "env:MustUnderstand"
219        );
220    }
221
222    #[test]
223    fn fault_code_data_encoding_unknown_as_soap11_str() {
224        // DataEncodingUnknown has no SOAP 1.1 equivalent — maps to env:Server
225        assert_eq!(FaultCode::DataEncodingUnknown.as_soap11_str(), "env:Server");
226    }
227
228    #[test]
229    fn serialize_sender_fault_contains_env_sender() {
230        let fault = SoapFault::sender("Bad request");
231        let xml = String::from_utf8(fault.to_xml_bytes()).unwrap();
232        assert!(
233            xml.contains("<env:Value>env:Sender</env:Value>"),
234            "Expected env:Sender in XML, got: {xml}"
235        );
236    }
237
238    #[test]
239    fn serialize_receiver_fault_contains_env_receiver() {
240        let fault = SoapFault::receiver("Internal error");
241        let xml = String::from_utf8(fault.to_xml_bytes()).unwrap();
242        assert!(
243            xml.contains("<env:Value>env:Receiver</env:Value>"),
244            "Expected env:Receiver in XML, got: {xml}"
245        );
246    }
247
248    #[test]
249    fn serialize_fault_wraps_in_soap12_envelope() {
250        let fault = SoapFault::sender("test");
251        let xml = String::from_utf8(fault.to_xml_bytes()).unwrap();
252        assert!(
253            xml.contains(r#"xmlns:env="http://www.w3.org/2003/05/soap-envelope""#),
254            "Expected SOAP 1.2 namespace in XML, got: {xml}"
255        );
256        assert!(xml.starts_with("<env:Envelope"), "Expected envelope root");
257        assert!(xml.contains("<env:Body>"), "Expected Body element");
258        assert!(xml.contains("<env:Fault>"), "Expected Fault element");
259    }
260
261    #[test]
262    fn serialize_fault_reason_included() {
263        let fault = SoapFault::sender("Custom reason text");
264        let xml = String::from_utf8(fault.to_xml_bytes()).unwrap();
265        assert!(
266            xml.contains("Custom reason text"),
267            "Expected reason in XML, got: {xml}"
268        );
269        assert!(
270            xml.contains(r#"<env:Text xml:lang="en">"#),
271            "Expected Text element with lang"
272        );
273    }
274
275    #[test]
276    fn serialize_fault_no_detail_when_none() {
277        let fault = SoapFault::sender("test");
278        let xml = String::from_utf8(fault.to_xml_bytes()).unwrap();
279        assert!(
280            !xml.contains("<env:Detail>"),
281            "Expected no Detail element when detail is None, got: {xml}"
282        );
283    }
284
285    #[test]
286    fn serialize_fault_with_detail() {
287        // SOAP 1.2 env:Detail is element-only — a plain-text `detail` has no valid
288        // place there (F-3). The reason is already in Reason/Text, so no env:Detail
289        // is emitted. The reason string must still appear in the envelope.
290        let fault = SoapFault::new(
291            FaultCode::Receiver,
292            "Internal error",
293            Some("extra info".to_string()),
294        );
295        let xml = String::from_utf8(fault.to_xml_bytes()).unwrap();
296        assert!(
297            !xml.contains("<env:Detail>extra info</env:Detail>"),
298            "SOAP 1.2 must not emit char-data in env:Detail (F-3): {xml}"
299        );
300        assert!(
301            !xml.contains("<env:Detail>"),
302            "No env:Detail at all for plain-text detail (F-3): {xml}"
303        );
304        assert!(
305            xml.contains("Internal error"),
306            "Reason must still be in Reason/Text: {xml}"
307        );
308    }
309
310    #[test]
311    fn version_mismatch_convenience() {
312        let fault = SoapFault::version_mismatch();
313        assert_eq!(fault.code, FaultCode::VersionMismatch);
314        let xml = String::from_utf8(fault.to_xml_bytes()).unwrap();
315        assert!(xml.contains("<env:Value>env:VersionMismatch</env:Value>"));
316    }
317
318    #[test]
319    fn must_understand_convenience() {
320        let fault = SoapFault::must_understand("MyHeader");
321        assert_eq!(fault.code, FaultCode::MustUnderstand);
322        let xml = String::from_utf8(fault.to_xml_bytes()).unwrap();
323        assert!(xml.contains("<env:Value>env:MustUnderstand</env:Value>"));
324        assert!(xml.contains("MyHeader"));
325    }
326
327    #[test]
328    fn action_not_supported_is_sender_fault() {
329        let fault = SoapFault::action_not_supported("urn:SomeAction");
330        assert_eq!(fault.code, FaultCode::Sender);
331        let xml = String::from_utf8(fault.to_xml_bytes()).unwrap();
332        assert!(xml.contains("<env:Value>env:Sender</env:Value>"));
333        assert!(xml.contains("urn:SomeAction"));
334    }
335
336    // ── SOAP 1.1 fault tests (TDD RED) ───────────────────────────────────────
337
338    #[test]
339    fn fault_soap11_structure() {
340        let fault = SoapFault::sender("bad");
341        let xml = String::from_utf8(fault.to_xml_bytes_v11()).unwrap();
342        assert!(
343            xml.contains("<faultcode>"),
344            "Expected <faultcode>, got: {xml}"
345        );
346        assert!(
347            xml.contains("<faultstring>"),
348            "Expected <faultstring>, got: {xml}"
349        );
350        assert!(
351            xml.contains("SOAP-ENV:Fault"),
352            "Expected SOAP-ENV:Fault, got: {xml}"
353        );
354        assert!(
355            !xml.contains("<env:Code>"),
356            "Should NOT contain <env:Code>, got: {xml}"
357        );
358        assert!(
359            !xml.contains("<env:Reason>"),
360            "Should NOT contain <env:Reason>, got: {xml}"
361        );
362    }
363
364    #[test]
365    fn fault_soap11_namespace() {
366        let fault = SoapFault::sender("bad");
367        let xml = String::from_utf8(fault.to_xml_bytes_v11()).unwrap();
368        assert!(
369            xml.contains(r#"xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/""#),
370            "Expected SOAP 1.1 namespace on Envelope, got: {xml}"
371        );
372    }
373
374    #[test]
375    fn fault_soap11_wraps_in_envelope() {
376        let fault = SoapFault::sender("bad");
377        let xml = String::from_utf8(fault.to_xml_bytes_v11()).unwrap();
378        assert!(
379            xml.starts_with("<SOAP-ENV:Envelope"),
380            "Expected SOAP-ENV:Envelope root, got: {xml}"
381        );
382        assert!(
383            xml.contains("<SOAP-ENV:Body>"),
384            "Expected <SOAP-ENV:Body>, got: {xml}"
385        );
386    }
387
388    #[test]
389    fn fault_code_sender_maps_to_client() {
390        let fault = SoapFault::sender("bad");
391        let xml = String::from_utf8(fault.to_xml_bytes_v11()).unwrap();
392        assert!(
393            xml.contains("<faultcode>SOAP-ENV:Client</faultcode>"),
394            "Expected SOAP-ENV:Client, got: {xml}"
395        );
396    }
397
398    #[test]
399    fn fault_code_receiver_maps_to_server() {
400        let fault = SoapFault::receiver("internal");
401        let xml = String::from_utf8(fault.to_xml_bytes_v11()).unwrap();
402        assert!(
403            xml.contains("<faultcode>SOAP-ENV:Server</faultcode>"),
404            "Expected SOAP-ENV:Server, got: {xml}"
405        );
406    }
407
408    #[test]
409    fn fault_code_version_mismatch_soap11() {
410        let fault = SoapFault::version_mismatch();
411        let xml = String::from_utf8(fault.to_xml_bytes_v11()).unwrap();
412        assert!(
413            xml.contains("<faultcode>SOAP-ENV:VersionMismatch</faultcode>"),
414            "Expected SOAP-ENV:VersionMismatch, got: {xml}"
415        );
416    }
417
418    #[test]
419    fn fault_code_must_understand_soap11() {
420        let fault = SoapFault::must_understand("MyHeader");
421        let xml = String::from_utf8(fault.to_xml_bytes_v11()).unwrap();
422        assert!(
423            xml.contains("<faultcode>SOAP-ENV:MustUnderstand</faultcode>"),
424            "Expected SOAP-ENV:MustUnderstand, got: {xml}"
425        );
426    }
427
428    #[test]
429    fn fault_code_data_encoding_unknown_soap11() {
430        let fault = SoapFault::new(FaultCode::DataEncodingUnknown, "enc error", None);
431        let xml = String::from_utf8(fault.to_xml_bytes_v11()).unwrap();
432        assert!(
433            xml.contains("<faultcode>SOAP-ENV:Server</faultcode>"),
434            "Expected SOAP-ENV:Server (no 1.1 equivalent for DataEncodingUnknown), got: {xml}"
435        );
436    }
437
438    #[test]
439    fn fault_soap11_with_detail() {
440        let fault = SoapFault::new(
441            FaultCode::Receiver,
442            "Internal error",
443            Some("extra info".to_string()),
444        );
445        let xml = String::from_utf8(fault.to_xml_bytes_v11()).unwrap();
446        assert!(
447            xml.contains("<detail>extra info</detail>"),
448            "Expected <detail> element with content, got: {xml}"
449        );
450    }
451
452    #[test]
453    fn fault_soap11_no_detail_when_none() {
454        let fault = SoapFault::sender("test");
455        let xml = String::from_utf8(fault.to_xml_bytes_v11()).unwrap();
456        assert!(
457            !xml.contains("<detail>"),
458            "Expected no <detail> when detail is None, got: {xml}"
459        );
460    }
461
462    #[test]
463    fn to_xml_bytes_versioned_soap12_calls_existing() {
464        use crate::wsdl::definitions::SoapVersion;
465        let fault = SoapFault::sender("test");
466        let v12 = fault.to_xml_bytes_versioned(&SoapVersion::Soap12);
467        let existing = fault.to_xml_bytes();
468        assert_eq!(
469            v12, existing,
470            "Soap12 path should produce identical output to to_xml_bytes()"
471        );
472    }
473
474    #[test]
475    fn to_xml_bytes_versioned_soap11_calls_v11() {
476        use crate::wsdl::definitions::SoapVersion;
477        let fault = SoapFault::sender("test");
478        let v11_versioned = fault.to_xml_bytes_versioned(&SoapVersion::Soap11);
479        let v11_direct = fault.to_xml_bytes_v11();
480        assert_eq!(
481            v11_versioned, v11_direct,
482            "Soap11 path should produce identical output to to_xml_bytes_v11()"
483        );
484    }
485
486    // ── XML escaping tests (Finding #1) ──────────────────────────────────────
487
488    /// Verify that reason and detail containing all five XML special characters
489    /// (`& < > " '`) are properly escaped and the resulting document parses as
490    /// well-formed XML.  Uses quick_xml to parse the envelope so any unescaped
491    /// entity or unbound prefix would surface as a parse error.
492    #[test]
493    fn soap12_fault_special_chars_in_reason_and_detail_are_escaped() {
494        use quick_xml::events::Event;
495        use quick_xml::Reader;
496
497        let special = r#"& < > " '"#;
498        let fault = SoapFault::new(FaultCode::Sender, special, Some(special.to_string()));
499        let xml_bytes = fault.to_xml_bytes();
500        let xml_str = String::from_utf8(xml_bytes.clone()).unwrap();
501
502        // The raw ampersand must NOT appear unescaped in the output.
503        // We check that the literal "& " (ampersand-space) is absent — the namespace
504        // URI http://... does not contain "& ", so any match is from the dynamic value.
505        assert!(
506            !xml_str.contains("& "),
507            "Unescaped '& ' found in SOAP 1.2 fault XML: {xml_str}"
508        );
509        // The escaped forms must be present.
510        assert!(xml_str.contains("&amp;"), "Expected &amp;: {xml_str}");
511        assert!(xml_str.contains("&lt;"), "Expected &lt;: {xml_str}");
512        assert!(xml_str.contains("&gt;"), "Expected &gt;: {xml_str}");
513        assert!(xml_str.contains("&quot;"), "Expected &quot;: {xml_str}");
514        assert!(xml_str.contains("&apos;"), "Expected &apos;: {xml_str}");
515
516        // Parse with quick_xml to confirm the document is well-formed XML.
517        // Any unescaped `<` or bare `&` would cause a parse error here (the
518        // expect() would panic with an XML error rather than the assertions below).
519        // Parse with quick_xml to confirm the document is well-formed XML.
520        // A bare `&` or unescaped `<` would cause a parse error on the expect().
521        let mut reader = Reader::from_str(&xml_str);
522        reader.config_mut().trim_text(true);
523        let mut buf = Vec::new();
524        let mut event_count = 0usize;
525        loop {
526            let is_eof = {
527                let ev = reader
528                    .read_event_into(&mut buf)
529                    .expect("XML must parse as well-formed");
530                matches!(ev, Event::Eof)
531            };
532            buf.clear();
533            if is_eof {
534                break;
535            }
536            event_count += 1;
537        }
538        assert!(event_count > 0, "Expected at least one XML event");
539    }
540
541    // ── detail_xml (raw XML child) tests ────────────────────────────────────
542
543    #[test]
544    fn serialize_fault_with_raw_xml_detail_soap12() {
545        let fault = SoapFault::sender("bad")
546            .with_detail_xml(r#"<c:Info xmlns:c="urn:x"><c:Code>42</c:Code></c:Info>"#);
547        let xml = String::from_utf8(fault.to_xml_bytes()).unwrap();
548        // raw child present, NOT escaped:
549        assert!(
550            xml.contains(
551                r#"<env:Detail><c:Info xmlns:c="urn:x"><c:Code>42</c:Code></c:Info></env:Detail>"#
552            ),
553            "got: {xml}"
554        );
555        assert!(
556            !xml.contains("&lt;c:Info"),
557            "detail must not be escaped: {xml}"
558        );
559    }
560
561    #[test]
562    fn serialize_fault_with_raw_xml_detail_soap11() {
563        let fault =
564            SoapFault::sender("bad").with_detail_xml(r#"<c:Info xmlns:c="urn:x">x</c:Info>"#);
565        let xml = String::from_utf8(fault.to_xml_bytes_v11()).unwrap();
566        assert!(
567            xml.contains(r#"<detail><c:Info xmlns:c="urn:x">x</c:Info></detail>"#),
568            "got: {xml}"
569        );
570        assert!(
571            !xml.contains("&lt;c:Info"),
572            "detail must not be escaped: {xml}"
573        );
574    }
575
576    #[test]
577    fn detail_xml_takes_precedence_over_text_detail() {
578        let fault = SoapFault::new(FaultCode::Sender, "r", Some("plaintext".into()))
579            .with_detail_xml("<x:Y xmlns:x=\"urn:z\"/>");
580        let xml = String::from_utf8(fault.to_xml_bytes()).unwrap();
581        assert!(
582            xml.contains("<x:Y xmlns:x=\"urn:z\"/>"),
583            "raw xml should win: {xml}"
584        );
585        assert!(
586            !xml.contains("plaintext"),
587            "text detail should be suppressed when detail_xml set: {xml}"
588        );
589    }
590
591    #[test]
592    fn existing_text_detail_still_escaped_when_no_xml() {
593        // SOAP 1.2: plain-text `detail` has no valid place in element-only env:Detail
594        // (F-3). The human-readable message is already in Reason/Text. Assert no
595        // env:Detail element is emitted — the text is dropped, not wrapped.
596        let fault = SoapFault::new(FaultCode::Sender, "r", Some("a < b & c".into()));
597        let xml = String::from_utf8(fault.to_xml_bytes()).unwrap();
598        assert!(
599            !xml.contains("<env:Detail>"),
600            "SOAP 1.2: env:Detail must not be emitted for plain-text detail (F-3): {xml}"
601        );
602    }
603
604    // ── F-3: SOAP 1.2 env:Detail is element-only ────────────────────────────
605
606    /// SOAP 1.2 env:Detail has element-only content (xs:any children, no text).
607    /// When only a plain-text `detail` is set (no `detail_xml`), no env:Detail
608    /// element should appear in the output — the reason is already in Reason/Text.
609    #[test]
610    fn soap12_text_detail_does_not_emit_env_detail_chardata() {
611        let fault = SoapFault::new(
612            FaultCode::Receiver,
613            "Internal error",
614            Some("plain text detail".to_string()),
615        );
616        let xml = String::from_utf8(fault.to_xml_bytes()).unwrap();
617        // Must NOT emit <env:Detail> wrapping character data
618        assert!(
619            !xml.contains("<env:Detail>plain text detail</env:Detail>"),
620            "SOAP 1.2 env:Detail must not contain character data (F-3): {xml}"
621        );
622        // Must NOT emit env:Detail at all when only text detail is present
623        assert!(
624            !xml.contains("<env:Detail>"),
625            "SOAP 1.2 env:Detail must not be emitted for plain-text detail (F-3): {xml}"
626        );
627        // The reason must still be present in Reason/Text
628        assert!(
629            xml.contains("Internal error"),
630            "Reason must still appear in Reason/Text: {xml}"
631        );
632    }
633
634    /// SOAP 1.2: when detail_xml (a real element) is set, env:Detail is still emitted
635    /// with the raw element child — this path is valid and must remain unchanged.
636    #[test]
637    fn soap12_detail_xml_element_still_emits_env_detail() {
638        let fault = SoapFault::new(
639            FaultCode::Receiver,
640            "Internal error",
641            Some("plain text".to_string()),
642        )
643        .with_detail_xml("<c:Child xmlns:c=\"urn:x\"/>");
644        let xml = String::from_utf8(fault.to_xml_bytes()).unwrap();
645        assert!(
646            xml.contains("<env:Detail><c:Child xmlns:c=\"urn:x\"/></env:Detail>"),
647            "detail_xml element path must still emit env:Detail: {xml}"
648        );
649    }
650
651    #[test]
652    fn soap11_fault_special_chars_in_reason_and_detail_are_escaped() {
653        use quick_xml::events::Event;
654        use quick_xml::Reader;
655
656        let special = r#"& < > " '"#;
657        let fault = SoapFault::new(FaultCode::Sender, special, Some(special.to_string()));
658        let xml_bytes = fault.to_xml_bytes_v11();
659        let xml_str = String::from_utf8(xml_bytes).unwrap();
660
661        assert!(
662            !xml_str.contains("& "),
663            "Unescaped '& ' found in SOAP 1.1 fault XML: {xml_str}"
664        );
665        assert!(xml_str.contains("&amp;"), "Expected &amp;: {xml_str}");
666        assert!(xml_str.contains("&lt;"), "Expected &lt;: {xml_str}");
667
668        // Parse to confirm well-formed.
669        let mut reader = Reader::from_str(&xml_str);
670        reader.config_mut().trim_text(true);
671        let mut buf = Vec::new();
672        loop {
673            let is_eof = {
674                let ev = reader.read_event_into(&mut buf).expect("XML must parse");
675                matches!(ev, Event::Eof)
676            };
677            buf.clear();
678            if is_eof {
679                break;
680            }
681        }
682    }
683}