From 27e9e0db6f2dcb96bbaf8ebf39a545093ab7decd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrg=20Otter?= Date: Thu, 10 Sep 2026 21:53:59 +0200 Subject: [PATCH 1/2] Adding tests highlighting issue #1192 --- .../CountingFaultExceptionTransformer.cs | 25 ++++ .../DuplicateFault/CountingLoggerProvider.cs | 71 ++++++++++ .../DuplicateFault/DuplicateFaultTests.cs | 132 ++++++++++++++++++ .../StampingMessageInspector.cs | 34 +++++ src/SoapCore.Tests/DuplicateFault/Startup.cs | 68 +++++++++ .../DuplicateFault/ThrowingResponseFilter.cs | 20 +++ 6 files changed, 350 insertions(+) create mode 100644 src/SoapCore.Tests/DuplicateFault/CountingFaultExceptionTransformer.cs create mode 100644 src/SoapCore.Tests/DuplicateFault/CountingLoggerProvider.cs create mode 100644 src/SoapCore.Tests/DuplicateFault/DuplicateFaultTests.cs create mode 100644 src/SoapCore.Tests/DuplicateFault/StampingMessageInspector.cs create mode 100644 src/SoapCore.Tests/DuplicateFault/Startup.cs create mode 100644 src/SoapCore.Tests/DuplicateFault/ThrowingResponseFilter.cs diff --git a/src/SoapCore.Tests/DuplicateFault/CountingFaultExceptionTransformer.cs b/src/SoapCore.Tests/DuplicateFault/CountingFaultExceptionTransformer.cs new file mode 100644 index 00000000..6546644d --- /dev/null +++ b/src/SoapCore.Tests/DuplicateFault/CountingFaultExceptionTransformer.cs @@ -0,0 +1,25 @@ +using System; +using System.ServiceModel; +using System.ServiceModel.Channels; +using SoapCore.Extensibility; + +namespace SoapCore.Tests.DuplicateFault +{ + public class CountingFaultExceptionTransformer : IFaultExceptionTransformer + { + private int _provideFaultCallCount; + + public int ProvideFaultCallCount => _provideFaultCallCount; + + public Message ProvideFault(Exception exception, MessageVersion messageVersion, Message requestMessage, ConcurrentXmlNamespaceLookup xmlNamespaceLookup) + { + _provideFaultCallCount++; + + var faultException = new FaultException(new FaultReason(exception.Message), new FaultCode("Sender"), null); + var messageFault = faultException.CreateMessageFault(); + var bodyWriter = new MessageFaultBodyWriter(messageFault, messageVersion); + + return Message.CreateMessage(messageVersion, null, bodyWriter); + } + } +} diff --git a/src/SoapCore.Tests/DuplicateFault/CountingLoggerProvider.cs b/src/SoapCore.Tests/DuplicateFault/CountingLoggerProvider.cs new file mode 100644 index 00000000..7da5711b --- /dev/null +++ b/src/SoapCore.Tests/DuplicateFault/CountingLoggerProvider.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Logging; + +namespace SoapCore.Tests.DuplicateFault +{ + public sealed class CountingLoggerProvider : ILoggerProvider + { + private readonly List _errors = new List(); + + public IReadOnlyList Errors + { + get + { + lock (_errors) + { + return _errors.ToList(); + } + } + } + + public ILogger CreateLogger(string categoryName) + { + return new CountingLogger(this); + } + + public void Dispose() + { + } + + private void AddError(string message) + { + lock (_errors) + { + _errors.Add(message); + } + } + + private sealed class CountingLogger : ILogger + { + private readonly CountingLoggerProvider _provider; + + public CountingLogger(CountingLoggerProvider provider) + { + _provider = provider; + } + + public IDisposable BeginScope(TState state) + where TState : notnull + { + return null; + } + + public bool IsEnabled(LogLevel logLevel) + { + return true; + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) + { + if (logLevel != LogLevel.Error) + { + return; + } + + _provider.AddError(formatter(state, exception)); + } + } + } +} diff --git a/src/SoapCore.Tests/DuplicateFault/DuplicateFaultTests.cs b/src/SoapCore.Tests/DuplicateFault/DuplicateFaultTests.cs new file mode 100644 index 00000000..1e34fc98 --- /dev/null +++ b/src/SoapCore.Tests/DuplicateFault/DuplicateFaultTests.cs @@ -0,0 +1,132 @@ +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace SoapCore.Tests.DuplicateFault +{ + // https://github.com/DigDes/SoapCore/issues/1192 + [TestClass] + public class DuplicateFaultTests + { + private const string FaultMessage = "Test fault message"; + private const string ErrorLogMessage = "An error occurred processing the message"; + + [TestMethod] + public async Task ProvideFaultIsCalledOnce() + { + using (var host = CreateTestHost()) + { + await PostFaultingRequestAsync(host); + + var faultExceptionTransformer = host.Services.GetRequiredService(); + + Assert.AreEqual(1, faultExceptionTransformer.ProvideFaultCallCount); + } + } + + [TestMethod] + public async Task ErrorIsLoggedOnce() + { + using (var host = CreateTestHost()) + { + await PostFaultingRequestAsync(host); + + var loggerProvider = host.Services.GetRequiredService(); + + Assert.AreEqual(1, loggerProvider.Errors.Count(error => error == ErrorLogMessage)); + } + } + + [TestMethod] + public async Task BeforeSendReplyIsCalledOnce() + { + using (var host = CreateTestHost()) + { + await PostFaultingRequestAsync(host); + + var messageInspector = host.Services.GetRequiredService(); + + Assert.AreEqual(1, messageInspector.AfterReceiveRequestCallCount); + Assert.AreEqual(1, messageInspector.BeforeSendReplyCallCount); + } + } + + [TestMethod] + public async Task BeforeSendReplyIsCalledOnceWhenTheResponseFilterThrows() + { + using (var host = CreateTestHost(registerThrowingResponseFilter: true)) + { + await PostPingRequestAsync(host); + + var messageInspector = host.Services.GetRequiredService(); + + Assert.AreEqual(1, messageInspector.AfterReceiveRequestCallCount); + Assert.AreEqual(1, messageInspector.BeforeSendReplyCallCount); + } + } + + [TestMethod] + public async Task MessageHandedToBeforeSendReplyIsTheMessageThatIsSent() + { + using (var host = CreateTestHost()) + { + var response = await PostFaultingRequestAsync(host); + + Assert.IsTrue(response.Contains(FaultMessage), "The fault should carry the reason of the exception"); + Assert.IsTrue(response.Contains(StampingMessageInspector.HeaderName), "The fault should carry the header added by BeforeSendReply"); + Assert.IsTrue(response.Contains(StampingMessageInspector.HeaderValue), "The fault should carry the value written by BeforeSendReply"); + } + } + + private TestServer CreateTestHost(bool registerThrowingResponseFilter = false) + { + var webHostBuilder = new WebHostBuilder() + .UseStartup() + .UseSetting(Startup.ThrowingResponseFilterSetting, registerThrowingResponseFilter.ToString()); + + return new TestServer(webHostBuilder); + } + + private Task PostFaultingRequestAsync(TestServer host) + { + var bodyContent = $@" + {FaultMessage} + "; + + return PostAsync(host, "ThrowExceptionWithMessage", bodyContent); + } + + private Task PostPingRequestAsync(TestServer host) + { + var bodyContent = @" + Hello World + "; + + return PostAsync(host, "Ping", bodyContent); + } + + private async Task PostAsync(TestServer host, string soapAction, string bodyContent) + { + var body = $@" + + {bodyContent} + + +"; + + using (var content = new StringContent(body, Encoding.UTF8, "text/xml")) + using (var res = await host.CreateRequest("/Service.svc").AddHeader("SOAPAction", $@"""{soapAction}""").And(msg => msg.Content = content).PostAsync()) + { + Assert.AreEqual(HttpStatusCode.InternalServerError, res.StatusCode); + + return await res.Content.ReadAsStringAsync(); + } + } + } +} diff --git a/src/SoapCore.Tests/DuplicateFault/StampingMessageInspector.cs b/src/SoapCore.Tests/DuplicateFault/StampingMessageInspector.cs new file mode 100644 index 00000000..9cc24ef4 --- /dev/null +++ b/src/SoapCore.Tests/DuplicateFault/StampingMessageInspector.cs @@ -0,0 +1,34 @@ +using System.ServiceModel.Channels; +using SoapCore.Extensibility; +using SoapCore.ServiceModel; + +namespace SoapCore.Tests.DuplicateFault +{ + public class StampingMessageInspector : IMessageInspector2 + { + public const string HeaderName = "InspectorStamp"; + public const string HeaderNamespace = "urn:soapcore-tests"; + public const string HeaderValue = "was-here"; + + private int _afterReceiveRequestCallCount; + private int _beforeSendReplyCallCount; + + public int AfterReceiveRequestCallCount => _afterReceiveRequestCallCount; + + public int BeforeSendReplyCallCount => _beforeSendReplyCallCount; + + public object AfterReceiveRequest(ref Message message, ServiceDescription serviceDescription) + { + _afterReceiveRequestCallCount++; + + return null; + } + + public void BeforeSendReply(ref Message reply, ServiceDescription serviceDescription, object correlationState) + { + _beforeSendReplyCallCount++; + + reply.Headers.Add(MessageHeader.CreateHeader(HeaderName, HeaderNamespace, HeaderValue)); + } + } +} diff --git a/src/SoapCore.Tests/DuplicateFault/Startup.cs b/src/SoapCore.Tests/DuplicateFault/Startup.cs new file mode 100644 index 00000000..aa2c6a1f --- /dev/null +++ b/src/SoapCore.Tests/DuplicateFault/Startup.cs @@ -0,0 +1,68 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using SoapCore.Extensibility; + +namespace SoapCore.Tests.DuplicateFault +{ + public class Startup + { + public const string ThrowingResponseFilterSetting = "RegisterThrowingResponseFilter"; + + public Startup(IConfiguration configuration) + { + Configuration = configuration; + RegisterThrowingResponseFilter = configuration.GetValue(ThrowingResponseFilterSetting); + } + + public IConfiguration Configuration { get; } + + public bool RegisterThrowingResponseFilter { get; } + + public void ConfigureServices(IServiceCollection services) + { + var loggerProvider = new CountingLoggerProvider(); + var faultExceptionTransformer = new CountingFaultExceptionTransformer(); + var messageInspector = new StampingMessageInspector(); + + services.AddSingleton(loggerProvider); + services.AddLogging(logging => logging.AddProvider(loggerProvider)); + + services.AddSoapCore(); + services.TryAddSingleton(); + + services.AddSingleton(faultExceptionTransformer); + services.AddSingleton(faultExceptionTransformer); + + services.AddSingleton(messageInspector); + services.AddSoapMessageInspector(messageInspector); + + if (RegisterThrowingResponseFilter) + { + services.AddSoapMessageFilter(new ThrowingResponseFilter()); + } + + services.AddRouting(); + } + +#if !NETCOREAPP3_0_OR_GREATER + public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory) + { + app.UseSoapEndpoint("/Service.svc", new SoapEncoderOptions(), SoapSerializer.DataContractSerializer); + } +#else + public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILoggerFactory loggerFactory) + { + app.UseRouting(); + + app.UseEndpoints(x => + { + x.UseSoapEndpoint("/Service.svc", new SoapEncoderOptions(), SoapSerializer.DataContractSerializer); + }); + } +#endif + } +} diff --git a/src/SoapCore.Tests/DuplicateFault/ThrowingResponseFilter.cs b/src/SoapCore.Tests/DuplicateFault/ThrowingResponseFilter.cs new file mode 100644 index 00000000..350bbb7a --- /dev/null +++ b/src/SoapCore.Tests/DuplicateFault/ThrowingResponseFilter.cs @@ -0,0 +1,20 @@ +using System; +using System.ServiceModel.Channels; +using System.Threading.Tasks; +using SoapCore.Extensibility; + +namespace SoapCore.Tests.DuplicateFault +{ + public class ThrowingResponseFilter : IAsyncMessageFilter + { + public Task OnRequestExecuting(Message message) + { + return Task.CompletedTask; + } + + public Task OnResponseExecuting(Message message) + { + throw new InvalidOperationException("The response is not acceptable."); + } + } +} From 761ae2e191c12a9b932312921b8e6814bb59b43b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrg=20Otter?= Date: Thu, 10 Sep 2026 21:56:27 +0200 Subject: [PATCH 2/2] Fix duplicate fault response on the error path #1192 --- src/SoapCore/SoapEndpointMiddleware.cs | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/SoapCore/SoapEndpointMiddleware.cs b/src/SoapCore/SoapEndpointMiddleware.cs index c2c885e3..a4a6251e 100644 --- a/src/SoapCore/SoapEndpointMiddleware.cs +++ b/src/SoapCore/SoapEndpointMiddleware.cs @@ -291,6 +291,8 @@ private async Task ProcessOperation(HttpContext httpContext, IServiceProvider se Message requestMessage = null; Message responseMessage = null; + var correlationObjects2 = new List<(IMessageInspector2 inspector, object correlationObject)>(); + try { //Get the message @@ -305,13 +307,15 @@ private async Task ProcessOperation(HttpContext httpContext, IServiceProvider se var soapMessageProcessors = serviceProvider.GetServices().ToArray(); - var processorPipe = MakeProcessorPipe(soapMessageProcessors, httpContext, (requestMessage) => ProcessMessage(requestMessage, messageEncoder, asyncMessageFilters, httpContext, serviceProvider)); + var processorPipe = MakeProcessorPipe(soapMessageProcessors, httpContext, (requestMessage) => ProcessMessage(requestMessage, messageEncoder, asyncMessageFilters, httpContext, serviceProvider, correlationObjects2)); responseMessage = await processorPipe(requestMessage); } catch (Exception ex) { responseMessage = CreateErrorResponseMessage(ex, serviceProvider, requestMessage, messageEncoder, httpContext); + + correlationObjects2.ForEach(mi => mi.inspector.BeforeSendReply(ref responseMessage, _service, mi.correlationObject)); } if (responseMessage != null) @@ -452,7 +456,7 @@ Func> MakeProcessorPipe(int i = 0) return MakeProcessorPipe(); } - private async Task ProcessMessage(Message requestMessage, SoapMessageEncoder messageEncoder, IAsyncMessageFilter[] asyncMessageFilters, HttpContext httpContext, IServiceProvider serviceProvider) + private async Task ProcessMessage(Message requestMessage, SoapMessageEncoder messageEncoder, IAsyncMessageFilter[] asyncMessageFilters, HttpContext httpContext, IServiceProvider serviceProvider, List<(IMessageInspector2 inspector, object correlationObject)> correlationObjects2) { Message responseMessage; var soapAction = HeadersHelper.GetSoapAction(httpContext, ref requestMessage); @@ -464,7 +468,7 @@ private async Task ProcessMessage(Message requestMessage, SoapMessageEn } var messageInspector2s = serviceProvider.GetServices(); - var correlationObjects2 = messageInspector2s.Select(mi => (inspector: mi, correlationObject: mi.AfterReceiveRequest(ref requestMessage, _service))).ToList(); + correlationObjects2.AddRange(messageInspector2s.Select(mi => (inspector: mi, correlationObject: mi.AfterReceiveRequest(ref requestMessage, _service)))); // for getting soapaction and parameters in (optional) body // GetReaderAtBodyContents must not be called twice in one request @@ -526,14 +530,7 @@ private async Task ProcessMessage(Message requestMessage, SoapMessageEn httpContext.Response.Headers["SOAPAction"] = responseMessage.Headers.Action; correlationObjects2.ForEach(mi => mi.inspector.BeforeSendReply(ref responseMessage, _service, mi.correlationObject)); - } - catch (Exception ex) - { - responseMessage = CreateErrorResponseMessage(ex, serviceProvider, requestMessage, messageEncoder, httpContext); - - correlationObjects2.ForEach(mi => mi.inspector.BeforeSendReply(ref responseMessage, _service, mi.correlationObject)); - - throw; + correlationObjects2.Clear(); } finally {