feat(notifications): EWS CreateItem envelope builder + response parser
This commit is contained in:
@@ -0,0 +1,105 @@
|
|||||||
|
using System.Xml.Linq;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.NotificationService.Ews;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The shape of an EWS response body. This says what Exchange answered, not
|
||||||
|
/// whether the failure is worth retrying — that judgement belongs to the sender's classifier.
|
||||||
|
/// </summary>
|
||||||
|
public enum EwsResponseKind
|
||||||
|
{
|
||||||
|
/// <summary>The <c>CreateItemResponseMessage</c> reported <c>ResponseClass="Success"</c>.</summary>
|
||||||
|
Success,
|
||||||
|
|
||||||
|
/// <summary>The <c>CreateItemResponseMessage</c> reported a non-success response class.</summary>
|
||||||
|
Error,
|
||||||
|
|
||||||
|
/// <summary>The body carried a SOAP <c>Fault</c> instead of a response message.</summary>
|
||||||
|
Fault,
|
||||||
|
|
||||||
|
/// <summary>The body was not XML, was empty, or was XML of no recognised EWS shape.</summary>
|
||||||
|
Unparseable,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The outcome of parsing an EWS response body.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Kind">The response shape.</param>
|
||||||
|
/// <param name="ResponseCode">
|
||||||
|
/// The EWS response code (for example <c>NoError</c>, <c>ErrorServerBusy</c>), or <c>null</c>
|
||||||
|
/// when the body carried none.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="MessageText">The human-readable message or SOAP fault string, when present.</param>
|
||||||
|
public sealed record EwsParseResult(EwsResponseKind Kind, string? ResponseCode, string? MessageText);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses an EWS <c>CreateItem</c> response body into a <see cref="EwsParseResult"/>.
|
||||||
|
/// <para>
|
||||||
|
/// Pure and judgement-free by design: it reports the response shape and code and never decides
|
||||||
|
/// transient versus permanent, so the retry policy stays in one place in the sender.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static class EwsResponseParser
|
||||||
|
{
|
||||||
|
private static readonly XNamespace Soap = "http://schemas.xmlsoap.org/soap/envelope/";
|
||||||
|
private static readonly XNamespace Messages = "http://schemas.microsoft.com/exchange/services/2006/messages";
|
||||||
|
|
||||||
|
private static readonly EwsParseResult UnparseableResult =
|
||||||
|
new(EwsResponseKind.Unparseable, ResponseCode: null, MessageText: null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a response body. A malformed or unrecognised payload is reported as
|
||||||
|
/// <see cref="EwsResponseKind.Unparseable"/> rather than throwing — a broken response must not
|
||||||
|
/// take out the delivery path with an unhandled exception.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="responseBody">The raw HTTP response body returned by the EWS endpoint.</param>
|
||||||
|
/// <returns>The parsed shape, response code and message text.</returns>
|
||||||
|
public static EwsParseResult Parse(string responseBody)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(responseBody))
|
||||||
|
{
|
||||||
|
return UnparseableResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
XDocument document;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
document = XDocument.Parse(responseBody);
|
||||||
|
}
|
||||||
|
catch (System.Xml.XmlException)
|
||||||
|
{
|
||||||
|
return UnparseableResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
var responseMessage = document.Descendants(Messages + "CreateItemResponseMessage").FirstOrDefault();
|
||||||
|
if (responseMessage is not null)
|
||||||
|
{
|
||||||
|
var responseClass = (string?)responseMessage.Attribute("ResponseClass");
|
||||||
|
var kind = string.Equals(responseClass, "Success", StringComparison.Ordinal)
|
||||||
|
? EwsResponseKind.Success
|
||||||
|
: EwsResponseKind.Error;
|
||||||
|
|
||||||
|
return new EwsParseResult(
|
||||||
|
kind,
|
||||||
|
responseMessage.Element(Messages + "ResponseCode")?.Value,
|
||||||
|
responseMessage.Element(Messages + "MessageText")?.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
var fault = document.Descendants(Soap + "Fault").FirstOrDefault();
|
||||||
|
if (fault is not null)
|
||||||
|
{
|
||||||
|
// faultstring is unqualified in SOAP 1.1, and the detail's ResponseCode appears under
|
||||||
|
// several vendor namespaces (errors/types) depending on the fault — match on local name.
|
||||||
|
var faultString = fault.Descendants()
|
||||||
|
.FirstOrDefault(e => e.Name.LocalName == "faultstring")?.Value;
|
||||||
|
|
||||||
|
var detail = fault.Descendants().FirstOrDefault(e => e.Name.LocalName == "detail");
|
||||||
|
var responseCode = detail?.Descendants()
|
||||||
|
.FirstOrDefault(e => e.Name.LocalName == "ResponseCode")?.Value;
|
||||||
|
|
||||||
|
return new EwsParseResult(EwsResponseKind.Fault, responseCode, faultString);
|
||||||
|
}
|
||||||
|
|
||||||
|
return UnparseableResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.NotificationService.Ews;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the EWS <c>CreateItem</c> SOAP envelope used to send a notification email
|
||||||
|
/// through Exchange Web Services.
|
||||||
|
/// <para>
|
||||||
|
/// Pure and I/O-free: the HTTP send lives in the sender that consumes this. The envelope is
|
||||||
|
/// composed with LINQ-to-XML rather than string concatenation so operator-authored subject and
|
||||||
|
/// body content is XML-escaped by construction.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static class EwsSoapEnvelope
|
||||||
|
{
|
||||||
|
private static readonly XNamespace Soap = "http://schemas.xmlsoap.org/soap/envelope/";
|
||||||
|
private static readonly XNamespace Types = "http://schemas.microsoft.com/exchange/services/2006/types";
|
||||||
|
private static readonly XNamespace Messages = "http://schemas.microsoft.com/exchange/services/2006/messages";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a <c>CreateItem</c> request that sends one plain-text message.
|
||||||
|
/// <para>
|
||||||
|
/// <c>MessageDisposition="SendOnly"</c> sends without keeping a Sent Items copy, and every
|
||||||
|
/// recipient is addressed via <c>BccRecipients</c> so recipients cannot see one another —
|
||||||
|
/// both mirror the SMTP delivery path's semantics.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fromAddress">The sending mailbox address stamped into <c>t:From</c>.</param>
|
||||||
|
/// <param name="bccRecipients">
|
||||||
|
/// The recipient addresses; each becomes one <c>t:Mailbox</c> under <c>t:BccRecipients</c>.
|
||||||
|
/// Callers are expected to supply at least one — an empty list yields an envelope Exchange
|
||||||
|
/// rejects as schema-invalid.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="subject">The message subject; escaped, not interpreted.</param>
|
||||||
|
/// <param name="body">The plain-text message body (<c>BodyType="Text"</c>); escaped, not interpreted.</param>
|
||||||
|
/// <returns>The complete SOAP envelope as XML text, prefixed with a UTF-8 declaration.</returns>
|
||||||
|
public static string BuildCreateItem(
|
||||||
|
string fromAddress,
|
||||||
|
IReadOnlyList<string> bccRecipients,
|
||||||
|
string subject,
|
||||||
|
string body)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(fromAddress);
|
||||||
|
ArgumentNullException.ThrowIfNull(bccRecipients);
|
||||||
|
ArgumentNullException.ThrowIfNull(subject);
|
||||||
|
ArgumentNullException.ThrowIfNull(body);
|
||||||
|
|
||||||
|
// EWS MessageType is an xs:sequence: Subject, Body, BccRecipients, From must appear in
|
||||||
|
// this order or the server answers ErrorSchemaValidation.
|
||||||
|
var message = new XElement(
|
||||||
|
Types + "Message",
|
||||||
|
new XElement(Types + "Subject", subject),
|
||||||
|
new XElement(Types + "Body", new XAttribute("BodyType", "Text"), body),
|
||||||
|
new XElement(Types + "BccRecipients", bccRecipients.Select(Mailbox)),
|
||||||
|
new XElement(Types + "From", Mailbox(fromAddress)));
|
||||||
|
|
||||||
|
var document = new XDocument(
|
||||||
|
new XDeclaration("1.0", "utf-8", null),
|
||||||
|
new XElement(
|
||||||
|
Soap + "Envelope",
|
||||||
|
new XAttribute(XNamespace.Xmlns + "soap", Soap.NamespaceName),
|
||||||
|
new XAttribute(XNamespace.Xmlns + "t", Types.NamespaceName),
|
||||||
|
new XAttribute(XNamespace.Xmlns + "m", Messages.NamespaceName),
|
||||||
|
new XElement(
|
||||||
|
Soap + "Header",
|
||||||
|
new XElement(Types + "RequestServerVersion", new XAttribute("Version", "Exchange2013"))),
|
||||||
|
new XElement(
|
||||||
|
Soap + "Body",
|
||||||
|
new XElement(
|
||||||
|
Messages + "CreateItem",
|
||||||
|
new XAttribute("MessageDisposition", "SendOnly"),
|
||||||
|
new XElement(Messages + "Items", message)))));
|
||||||
|
|
||||||
|
// XDocument.ToString() drops the declaration; saving through a writer that reports UTF-8
|
||||||
|
// keeps it, and keeps the declared encoding honest.
|
||||||
|
using var writer = new Utf8StringWriter();
|
||||||
|
document.Save(writer);
|
||||||
|
return writer.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static XElement Mailbox(string address)
|
||||||
|
=> new(Types + "Mailbox", new XElement(Types + "EmailAddress", address));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A <see cref="StringWriter"/> that reports UTF-8 so <see cref="XDocument.Save(TextWriter)"/>
|
||||||
|
/// emits <c>encoding="utf-8"</c> instead of the default UTF-16.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class Utf8StringWriter : StringWriter
|
||||||
|
{
|
||||||
|
public override Encoding Encoding => Encoding.UTF8;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
using ZB.MOM.WW.ScadaBridge.NotificationService.Ews;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests.Ews;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the pure EWS response parser. The parser reports shape only
|
||||||
|
/// (success / error / SOAP fault / unparseable) plus the response code — the
|
||||||
|
/// transient-vs-permanent judgement belongs to the sender's classifier.
|
||||||
|
/// </summary>
|
||||||
|
public class EwsResponseParserTests
|
||||||
|
{
|
||||||
|
private const string SuccessBody = """
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||||
|
<s:Body>
|
||||||
|
<m:CreateItemResponse xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages"
|
||||||
|
xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types">
|
||||||
|
<m:ResponseMessages>
|
||||||
|
<m:CreateItemResponseMessage ResponseClass="Success">
|
||||||
|
<m:ResponseCode>NoError</m:ResponseCode>
|
||||||
|
<m:Items><t:Message><t:ItemId Id="AAA=" ChangeKey="CQ=="/></t:Message></m:Items>
|
||||||
|
</m:CreateItemResponseMessage>
|
||||||
|
</m:ResponseMessages>
|
||||||
|
</m:CreateItemResponse>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string ErrorBody = """
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||||
|
<s:Body>
|
||||||
|
<m:CreateItemResponse xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages"
|
||||||
|
xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types">
|
||||||
|
<m:ResponseMessages>
|
||||||
|
<m:CreateItemResponseMessage ResponseClass="Error">
|
||||||
|
<m:MessageText>server busy</m:MessageText>
|
||||||
|
<m:ResponseCode>ErrorServerBusy</m:ResponseCode>
|
||||||
|
<m:DescriptiveLinkKey>0</m:DescriptiveLinkKey>
|
||||||
|
</m:CreateItemResponseMessage>
|
||||||
|
</m:ResponseMessages>
|
||||||
|
</m:CreateItemResponse>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string FaultWithResponseCodeBody = """
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||||
|
<s:Body>
|
||||||
|
<s:Fault>
|
||||||
|
<faultcode xmlns:a="http://schemas.microsoft.com/exchange/services/2006/types">a:ErrorSchemaValidation</faultcode>
|
||||||
|
<faultstring xml:lang="en-US">The request failed schema validation.</faultstring>
|
||||||
|
<detail>
|
||||||
|
<e:ResponseCode xmlns:e="http://schemas.microsoft.com/exchange/services/2006/errors">ErrorSchemaValidation</e:ResponseCode>
|
||||||
|
<e:Message xmlns:e="http://schemas.microsoft.com/exchange/services/2006/errors">The request failed schema validation.</e:Message>
|
||||||
|
</detail>
|
||||||
|
</s:Fault>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string FaultWithoutResponseCodeBody = """
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||||
|
<s:Body>
|
||||||
|
<s:Fault>
|
||||||
|
<faultcode>s:Server</faultcode>
|
||||||
|
<faultstring xml:lang="en-US">An internal server error occurred.</faultstring>
|
||||||
|
<detail><e:Message xmlns:e="http://schemas.microsoft.com/exchange/services/2006/errors">boom</e:Message></detail>
|
||||||
|
</s:Fault>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>
|
||||||
|
""";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_SuccessResponse_ReturnsSuccessWithNoError()
|
||||||
|
{
|
||||||
|
var result = EwsResponseParser.Parse(SuccessBody);
|
||||||
|
|
||||||
|
Assert.Equal(EwsResponseKind.Success, result.Kind);
|
||||||
|
Assert.Equal("NoError", result.ResponseCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_ErrorResponse_ReturnsErrorWithCodeAndMessage()
|
||||||
|
{
|
||||||
|
var result = EwsResponseParser.Parse(ErrorBody);
|
||||||
|
|
||||||
|
Assert.Equal(EwsResponseKind.Error, result.Kind);
|
||||||
|
Assert.Equal("ErrorServerBusy", result.ResponseCode);
|
||||||
|
Assert.Equal("server busy", result.MessageText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_SoapFaultWithDetailResponseCode_ReturnsFaultWithCode()
|
||||||
|
{
|
||||||
|
var result = EwsResponseParser.Parse(FaultWithResponseCodeBody);
|
||||||
|
|
||||||
|
Assert.Equal(EwsResponseKind.Fault, result.Kind);
|
||||||
|
Assert.Equal("ErrorSchemaValidation", result.ResponseCode);
|
||||||
|
Assert.Equal("The request failed schema validation.", result.MessageText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_SoapFaultWithoutResponseCode_ReturnsFaultWithNullCode()
|
||||||
|
{
|
||||||
|
var result = EwsResponseParser.Parse(FaultWithoutResponseCodeBody);
|
||||||
|
|
||||||
|
Assert.Equal(EwsResponseKind.Fault, result.Kind);
|
||||||
|
Assert.Null(result.ResponseCode);
|
||||||
|
Assert.Equal("An internal server error occurred.", result.MessageText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("not xml")]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
[InlineData("<foo/>")]
|
||||||
|
public void Parse_GarbageOrUnrelatedPayload_ReturnsUnparseable(string body)
|
||||||
|
{
|
||||||
|
var result = EwsResponseParser.Parse(body);
|
||||||
|
|
||||||
|
Assert.Equal(EwsResponseKind.Unparseable, result.Kind);
|
||||||
|
Assert.Null(result.ResponseCode);
|
||||||
|
Assert.Null(result.MessageText);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using System.Xml.Linq;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.NotificationService.Ews;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests.Ews;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the pure EWS <c>CreateItem</c> SOAP envelope builder: the fixed schema
|
||||||
|
/// sequence Exchange validates against, BCC-only recipients, and XML escaping of
|
||||||
|
/// operator-authored subject/body content.
|
||||||
|
/// </summary>
|
||||||
|
public class EwsSoapEnvelopeTests
|
||||||
|
{
|
||||||
|
private static readonly XNamespace Soap = "http://schemas.xmlsoap.org/soap/envelope/";
|
||||||
|
private static readonly XNamespace Types = "http://schemas.microsoft.com/exchange/services/2006/types";
|
||||||
|
private static readonly XNamespace Messages = "http://schemas.microsoft.com/exchange/services/2006/messages";
|
||||||
|
|
||||||
|
private static XDocument Build(
|
||||||
|
string from = "scada@example.com",
|
||||||
|
IReadOnlyList<string>? bcc = null,
|
||||||
|
string subject = "Subject",
|
||||||
|
string body = "Body")
|
||||||
|
=> XDocument.Parse(EwsSoapEnvelope.BuildCreateItem(from, bcc ?? ["one@example.com"], subject, body));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildCreateItem_SetsSendOnlyDispositionAndExchange2013RequestVersion()
|
||||||
|
{
|
||||||
|
var doc = Build();
|
||||||
|
|
||||||
|
var createItem = doc.Descendants(Messages + "CreateItem").Single();
|
||||||
|
Assert.Equal("SendOnly", (string?)createItem.Attribute("MessageDisposition"));
|
||||||
|
|
||||||
|
var version = doc.Descendants(Types + "RequestServerVersion").Single();
|
||||||
|
Assert.Equal("Exchange2013", (string?)version.Attribute("Version"));
|
||||||
|
Assert.Single(doc.Descendants(Soap + "Header").Single().Elements());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildCreateItem_EscapesMarkupInSubjectAndBody()
|
||||||
|
{
|
||||||
|
const string subject = "Alarm <HIGH> & \"critical\"";
|
||||||
|
const string body = "value < 5 & tag=\"A\" > threshold";
|
||||||
|
|
||||||
|
var doc = Build(subject: subject, body: body);
|
||||||
|
|
||||||
|
// Round-tripping through the parser proves the content was escaped, not concatenated.
|
||||||
|
Assert.Equal(subject, doc.Descendants(Types + "Subject").Single().Value);
|
||||||
|
Assert.Equal(body, doc.Descendants(Types + "Body").Single().Value);
|
||||||
|
Assert.Equal("Text", (string?)doc.Descendants(Types + "Body").Single().Attribute("BodyType"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildCreateItem_PutsEveryRecipientInBccAndNeverEmitsToRecipients()
|
||||||
|
{
|
||||||
|
string[] recipients = ["a@example.com", "b@example.com", "c@example.com"];
|
||||||
|
|
||||||
|
var doc = Build(bcc: recipients);
|
||||||
|
|
||||||
|
var bcc = doc.Descendants(Types + "BccRecipients").Single();
|
||||||
|
var addresses = bcc.Elements(Types + "Mailbox")
|
||||||
|
.Select(m => m.Element(Types + "EmailAddress")!.Value)
|
||||||
|
.ToArray();
|
||||||
|
Assert.Equal(recipients, addresses);
|
||||||
|
|
||||||
|
// Recipient privacy is the whole reason for BCC-only: no To/Cc may leak the list.
|
||||||
|
Assert.DoesNotContain(doc.Descendants(), e => e.Name.LocalName is "ToRecipients" or "CcRecipients");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildCreateItem_EmitsFromMailbox()
|
||||||
|
{
|
||||||
|
var doc = Build(from: "scada-alerts@example.com");
|
||||||
|
|
||||||
|
var from = doc.Descendants(Types + "From").Single();
|
||||||
|
var mailbox = from.Elements(Types + "Mailbox").Single();
|
||||||
|
Assert.Equal("scada-alerts@example.com", mailbox.Element(Types + "EmailAddress")!.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildCreateItem_OrdersMessageChildrenPerEwsSchemaSequence()
|
||||||
|
{
|
||||||
|
var doc = Build();
|
||||||
|
|
||||||
|
// MessageType is an xs:sequence — any other order is ErrorSchemaValidation at the server.
|
||||||
|
var message = doc.Descendants(Types + "Message").Single();
|
||||||
|
Assert.Equal(
|
||||||
|
new[] { "Subject", "Body", "BccRecipients", "From" },
|
||||||
|
message.Elements().Select(e => e.Name.LocalName).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildCreateItem_StartsWithUtf8XmlDeclaration()
|
||||||
|
{
|
||||||
|
var xml = EwsSoapEnvelope.BuildCreateItem("a@example.com", ["b@example.com"], "s", "b");
|
||||||
|
|
||||||
|
Assert.StartsWith("<?xml", xml, StringComparison.Ordinal);
|
||||||
|
var declaration = xml[..xml.IndexOf("?>", StringComparison.Ordinal)];
|
||||||
|
Assert.Contains("utf-8", declaration, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user