Fix: Fully respect configured custom admin path

This commit is contained in:
Peter Benko
2026-07-06 18:27:11 +02:00
parent 4666757df9
commit 1ab6ca5531
10 changed files with 113 additions and 63 deletions
@@ -66,6 +66,7 @@ internal partial class AspNetCoreSelfHost
.ConfigureServices(services => .ConfigureServices(services =>
{ {
services.AddSingleton(_wireMockMiddlewareOptions); services.AddSingleton(_wireMockMiddlewareOptions);
services.AddSingleton(_wireMockMiddlewareOptions.AdminPaths);
services.AddSingleton<IMappingMatcher, MappingMatcher>(); services.AddSingleton<IMappingMatcher, MappingMatcher>();
services.AddSingleton<IRandomizerDoubleBetween0And1, RandomizerDoubleBetween0And1>(); services.AddSingleton<IRandomizerDoubleBetween0And1, RandomizerDoubleBetween0And1>();
services.AddSingleton<IOwinRequestMapper, OwinRequestMapper>(); services.AddSingleton<IOwinRequestMapper, OwinRequestMapper>();
@@ -0,0 +1,28 @@
using WireMock.Matchers;
namespace WireMock.Owin;
internal interface IAdminPaths
{
string Files { get; }
string Health { get; }
string Mappings { get; }
string MappingsCode { get; }
string MappingsWireMockOrg { get; }
RegexMatcher MappingsGuidPathMatcher { get; }
RegexMatcher MappingsGuidEnablePathMatcher { get; }
RegexMatcher MappingsGuidDisablePathMatcher { get; }
RegexMatcher MappingsCodeGuidPathMatcher { get; }
RegexMatcher RequestsGuidPathMatcher { get; }
RegexMatcher ScenariosNameMatcher { get; }
RegexMatcher ScenariosNameWithStateMatcher { get; }
RegexMatcher ScenariosNameWithResetMatcher { get; }
RegexMatcher FilesFilenamePathMatcher { get; }
RegexMatcher ProtoDefinitionsIdPathMatcher { get; }
string Requests { get; }
string Settings { get; }
string Scenarios { get; }
string OpenApi { get; }
string ProtoDefinitions { get; }
bool Includes(string? path);
}
@@ -108,4 +108,6 @@ internal interface IWireMockMiddlewareOptions
/// Set this property to customize how objects are serialized to and deserialized from JSON during mapping. /// Set this property to customize how objects are serialized to and deserialized from JSON during mapping.
/// </remarks> /// </remarks>
IJsonConverter DefaultJsonSerializer { get; set; } IJsonConverter DefaultJsonSerializer { get; set; }
IAdminPaths AdminPaths { get; set; }
} }
@@ -21,6 +21,7 @@ internal class WireMockMiddleware(
RequestDelegate next, RequestDelegate next,
#pragma warning restore CS9113 // Parameter is unread. #pragma warning restore CS9113 // Parameter is unread.
IWireMockMiddlewareOptions options, IWireMockMiddlewareOptions options,
IAdminPaths adminPaths,
IOwinRequestMapper requestMapper, IOwinRequestMapper requestMapper,
IOwinResponseMapper responseMapper, IOwinResponseMapper responseMapper,
IMappingMatcher mappingMatcher, IMappingMatcher mappingMatcher,
@@ -64,7 +65,7 @@ internal class WireMockMiddleware(
Activity? activity = null; Activity? activity = null;
// Check if we should trace this request (optionally exclude admin requests) // Check if we should trace this request (optionally exclude admin requests)
var shouldTrace = tracingEnabled && !(excludeAdmin && request.Path.StartsWith("/__admin/")); var shouldTrace = tracingEnabled && !(excludeAdmin && adminPaths.Includes(request.Path));
if (shouldTrace) if (shouldTrace)
{ {
@@ -9,39 +9,43 @@ using WireMock.Util;
namespace WireMock.Owin; namespace WireMock.Owin;
internal class WireMockMiddlewareLogger( internal class WireMockMiddlewareLogger(
IWireMockMiddlewareOptions _options, IWireMockMiddlewareOptions options,
LogEntryMapper _logEntryMapper, LogEntryMapper logEntryMapper,
IGuidUtils _guidUtils IGuidUtils guidUtils,
IAdminPaths adminPaths
) : IWireMockMiddlewareLogger ) : IWireMockMiddlewareLogger
{ {
public void LogRequestAndResponse(bool logRequest, RequestMessage request, IResponseMessage? response, MappingMatcherResult? match, MappingMatcherResult? partialMatch, Activity? activity) public void LogRequestAndResponse(bool logRequest, RequestMessage request, IResponseMessage? response, MappingMatcherResult? match, MappingMatcherResult? partialMatch, Activity? activity)
{ {
var mapping = match?.Mapping;
var partialMapping = partialMatch?.Mapping;
var logEntry = new LogEntry var logEntry = new LogEntry
{ {
Guid = _guidUtils.NewGuid(), Guid = guidUtils.NewGuid(),
RequestMessage = request, RequestMessage = request,
ResponseMessage = response, ResponseMessage = response,
MappingGuid = match?.Mapping?.Guid, MappingGuid = mapping?.Guid,
MappingTitle = match?.Mapping?.Title, MappingTitle = mapping?.Title,
RequestMatchResult = match?.RequestMatchResult, RequestMatchResult = match?.RequestMatchResult,
PartialMappingGuid = partialMatch?.Mapping?.Guid, PartialMappingGuid = partialMapping?.Guid,
PartialMappingTitle = partialMatch?.Mapping?.Title, PartialMappingTitle = partialMapping?.Title,
PartialMatchResult = partialMatch?.RequestMatchResult PartialMatchResult = partialMatch?.RequestMatchResult
}; };
WireMockActivitySource.EnrichWithLogEntry(activity, logEntry, _options.ActivityTracingOptions); WireMockActivitySource.EnrichWithLogEntry(activity, logEntry, options.ActivityTracingOptions);
activity?.Dispose(); activity?.Dispose();
LogLogEntry(logEntry, logRequest); LogLogEntry(logEntry, logRequest);
try try
{ {
if (_options.SaveUnmatchedRequests == true && match?.RequestMatchResult is not { IsPerfectMatch: true }) if (options.SaveUnmatchedRequests == true && match?.RequestMatchResult is not { IsPerfectMatch: true })
{ {
var filename = $"{logEntry.Guid}.LogEntry.json"; var filename = $"{logEntry.Guid}.LogEntry.json";
_options.FileSystemHandler?.WriteUnmatchedRequest(filename, _options.DefaultJsonSerializer.Serialize(logEntry)); options.FileSystemHandler?.WriteUnmatchedRequest(filename, options.DefaultJsonSerializer.Serialize(logEntry));
} }
} }
catch catch
@@ -52,34 +56,35 @@ internal class WireMockMiddlewareLogger(
public void LogLogEntry(LogEntry entry, bool addRequest) public void LogLogEntry(LogEntry entry, bool addRequest)
{ {
_options.Logger.DebugRequestResponse(_logEntryMapper.Map(entry), entry.RequestMessage?.Path.StartsWith("/__admin/") == true); var isAdminRequest = adminPaths.Includes(entry.RequestMessage?.Path);
options.Logger.DebugRequestResponse(logEntryMapper.Map(entry), isAdminRequest);
// If addRequest is set to true and MaxRequestLogCount is null or does have a value greater than 0, try to add a new request log. // If addRequest is set to true and MaxRequestLogCount is null or does have a value greater than 0, try to add a new request log.
if (addRequest && _options.MaxRequestLogCount is null or > 0) if (addRequest && options.MaxRequestLogCount is null or > 0)
{ {
TryAddLogEntry(entry); TryAddLogEntry(entry);
} }
// In case MaxRequestLogCount has a value greater than 0, try to delete existing request logs based on the count. // In case MaxRequestLogCount has a value greater than 0, try to delete existing request logs based on the count.
if (_options.MaxRequestLogCount is > 0) if (options.MaxRequestLogCount is > 0)
{ {
var logEntries = _options.LogEntries.ToList(); var logEntries = options.LogEntries.ToList();
foreach (var logEntry in logEntries foreach (var logEntry in logEntries
.OrderBy(le => le.RequestMessage?.DateTime ?? le.ResponseMessage?.DateTime) .OrderBy(le => le.RequestMessage?.DateTime ?? le.ResponseMessage?.DateTime)
.Take(logEntries.Count - _options.MaxRequestLogCount.Value)) .Take(logEntries.Count - options.MaxRequestLogCount.Value))
{ {
TryRemoveLogEntry(logEntry); TryRemoveLogEntry(logEntry);
} }
} }
// In case RequestLogExpirationDuration has a value greater than 0, try to delete existing request logs based on the date. // In case RequestLogExpirationDuration has a value greater than 0, try to delete existing request logs based on the date.
if (_options.RequestLogExpirationDuration is > 0) if (options.RequestLogExpirationDuration is > 0)
{ {
var logEntries = _options.LogEntries.ToList(); var logEntries = options.LogEntries.ToList();
var checkTime = DateTime.UtcNow.AddHours(-_options.RequestLogExpirationDuration.Value); var checkTime = DateTime.UtcNow.AddHours(-options.RequestLogExpirationDuration.Value);
foreach (var logEntry in logEntries.Where(le => le.RequestMessage?.DateTime < checkTime || le.ResponseMessage?.DateTime < checkTime)) foreach (var logEntry in logEntries.Where(le => le.RequestMessage?.DateTime < checkTime || le.ResponseMessage?.DateTime < checkTime))
{ {
TryRemoveLogEntry(logEntry); TryRemoveLogEntry(logEntry);
@@ -91,7 +96,7 @@ internal class WireMockMiddlewareLogger(
{ {
try try
{ {
_options.LogEntries.Add(logEntry); options.LogEntries.Add(logEntry);
} }
catch catch
{ {
@@ -103,7 +108,7 @@ internal class WireMockMiddlewareLogger(
{ {
try try
{ {
_options.LogEntries.Remove(logEntry); options.LogEntries.Remove(logEntry);
} }
catch catch
{ {
@@ -115,4 +115,7 @@ internal class WireMockMiddlewareOptions : IWireMockMiddlewareOptions
/// <inheritdoc /> /// <inheritdoc />
public IJsonConverter DefaultJsonSerializer { get; set; } = new NewtonsoftJsonConverter(); public IJsonConverter DefaultJsonSerializer { get; set; } = new NewtonsoftJsonConverter();
/// <inheritdoc />
public IAdminPaths AdminPaths { get; set; } = new AdminPaths(null);
} }
@@ -34,6 +34,7 @@ internal static class WireMockMiddlewareOptionsHelper
options.QueryParameterMultipleValueSupport = settings.QueryParameterMultipleValueSupport; options.QueryParameterMultipleValueSupport = settings.QueryParameterMultipleValueSupport;
options.RequestLogExpirationDuration = settings.RequestLogExpirationDuration; options.RequestLogExpirationDuration = settings.RequestLogExpirationDuration;
options.SaveUnmatchedRequests = settings.SaveUnmatchedRequests; options.SaveUnmatchedRequests = settings.SaveUnmatchedRequests;
options.AdminPaths = new AdminPaths(settings.AdminPath);
#if USE_ASPNETCORE #if USE_ASPNETCORE
options.AdditionalServiceRegistration = settings.AdditionalServiceRegistration; options.AdditionalServiceRegistration = settings.AdditionalServiceRegistration;
@@ -0,0 +1,40 @@
// Copyright © WireMock.Net
using System.Text.RegularExpressions;
using WireMock.Matchers;
namespace WireMock.Owin;
internal sealed class AdminPaths(string? adminPath) : IAdminPaths
{
private const string DefaultAdminPathPrefix = "/__admin";
private readonly string _prefix = adminPath ?? DefaultAdminPathPrefix;
public string Files => $"{_prefix}/files";
public string Health => $"{_prefix}/health";
public string Mappings => $"{_prefix}/mappings";
public string MappingsCode => $"{_prefix}/mappings/code";
public string MappingsWireMockOrg => $"{_prefix}mappings/wiremock.org";
public string Requests => $"{_prefix}/requests";
public string Settings => $"{_prefix}/settings";
public string Scenarios => $"{_prefix}/scenarios";
public string OpenApi => $"{_prefix}/openapi";
public string ProtoDefinitions => $"{_prefix}/protodefinitions";
private string PrefixRegexEscaped => Regex.Escape(_prefix);
public RegexMatcher MappingsGuidPathMatcher => new($@"^{PrefixRegexEscaped}\/mappings\/([0-9A-Fa-f]{{8}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{12}})$");
public RegexMatcher MappingsGuidEnablePathMatcher => new($@"^{PrefixRegexEscaped}\/mappings\/([0-9A-Fa-f]{{8}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{12}})\/enable$");
public RegexMatcher MappingsGuidDisablePathMatcher => new($@"^{PrefixRegexEscaped}\/mappings\/([0-9A-Fa-f]{{8}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{12}})\/disable$");
public RegexMatcher MappingsCodeGuidPathMatcher => new($@"^{PrefixRegexEscaped}\/mappings\/code\/([0-9A-Fa-f]{{8}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{12}})$");
public RegexMatcher RequestsGuidPathMatcher => new($@"^{PrefixRegexEscaped}\/requests\/([0-9A-Fa-f]{{8}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{12}})$");
public RegexMatcher ScenariosNameMatcher => new($@"^{PrefixRegexEscaped}\/scenarios\/.+$");
public RegexMatcher ScenariosNameWithStateMatcher => new($@"^{PrefixRegexEscaped}\/scenarios\/.+\/state$");
public RegexMatcher ScenariosNameWithResetMatcher => new($@"^{PrefixRegexEscaped}\/scenarios\/.+\/reset$");
public RegexMatcher FilesFilenamePathMatcher => new($@"^{PrefixRegexEscaped}\/files\/.+$");
public RegexMatcher ProtoDefinitionsIdPathMatcher => new($@"^{PrefixRegexEscaped}\/protodefinitions\/.+$");
public bool Includes(string? path) => !string.IsNullOrEmpty(path) && path.StartsWith($"{_prefix}/");
public override string ToString() => _prefix;
}
@@ -19,7 +19,6 @@ using WireMock.Owin;
using WireMock.RequestBuilders; using WireMock.RequestBuilders;
using WireMock.ResponseProviders; using WireMock.ResponseProviders;
using WireMock.Serialization; using WireMock.Serialization;
using WireMock.Settings;
using WireMock.Types; using WireMock.Types;
using WireMock.Util; using WireMock.Util;
@@ -28,50 +27,16 @@ namespace WireMock.Server;
public partial class WireMockServer public partial class WireMockServer
{ {
private const int EnhancedFileSystemWatcherTimeoutMs = 1000; private const int EnhancedFileSystemWatcherTimeoutMs = 1000;
private const string DefaultAdminPathPrefix = "/__admin";
private const string QueryParamReloadStaticMappings = "reloadStaticMappings"; private const string QueryParamReloadStaticMappings = "reloadStaticMappings";
private static readonly Guid ProxyMappingGuid = new("e59914fd-782e-428e-91c1-4810ffb86567"); private static readonly Guid ProxyMappingGuid = new("e59914fd-782e-428e-91c1-4810ffb86567");
private static readonly RegexMatcher AdminRequestContentTypeJson = new ContentTypeMatcher(WireMockConstants.ContentTypeJson, true); private static readonly RegexMatcher AdminRequestContentTypeJson = new ContentTypeMatcher(WireMockConstants.ContentTypeJson, true);
private EnhancedFileSystemWatcher? _enhancedFileSystemWatcher; private EnhancedFileSystemWatcher? _enhancedFileSystemWatcher;
private AdminPaths? _adminPaths; private IAdminPaths? _adminPaths;
private sealed class AdminPaths
{
private readonly string _prefix;
private readonly string _prefixEscaped;
public AdminPaths(WireMockServerSettings settings)
{
_prefix = settings.AdminPath ?? DefaultAdminPathPrefix;
_prefixEscaped = _prefix.Replace("/", "\\/");
}
public string Files => $"{_prefix}/files";
public string Health => $"{_prefix}/health";
public string Mappings => $"{_prefix}/mappings";
public string MappingsCode => $"{_prefix}/mappings/code";
public string MappingsWireMockOrg => $"{_prefix}mappings/wiremock.org";
public string Requests => $"{_prefix}/requests";
public string Settings => $"{_prefix}/settings";
public string Scenarios => $"{_prefix}/scenarios";
public string OpenApi => $"{_prefix}/openapi";
public RegexMatcher MappingsGuidPathMatcher => new($"^{_prefixEscaped}\\/mappings\\/([0-9A-Fa-f]{{8}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{12}})$");
public RegexMatcher MappingsGuidEnablePathMatcher => new($"^{_prefixEscaped}\\/mappings\\/([0-9A-Fa-f]{{8}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{12}})\\/enable$");
public RegexMatcher MappingsGuidDisablePathMatcher => new($"^{_prefixEscaped}\\/mappings\\/([0-9A-Fa-f]{{8}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{12}})\\/disable$");
public RegexMatcher MappingsCodeGuidPathMatcher => new($"^{_prefixEscaped}\\/mappings\\/code\\/([0-9A-Fa-f]{{8}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{12}})$");
public RegexMatcher RequestsGuidPathMatcher => new($"^{_prefixEscaped}\\/requests\\/([0-9A-Fa-f]{{8}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{4}}[-][0-9A-Fa-f]{{12}})$");
public RegexMatcher ScenariosNameMatcher => new($"^{_prefixEscaped}\\/scenarios\\/.+$");
public RegexMatcher ScenariosNameWithStateMatcher => new($"^{_prefixEscaped}\\/scenarios\\/.+\\/state$");
public RegexMatcher ScenariosNameWithResetMatcher => new($"^{_prefixEscaped}\\/scenarios\\/.+\\/reset$");
public RegexMatcher FilesFilenamePathMatcher => new($"^{_prefixEscaped}\\/files\\/.+$");
public RegexMatcher ProtoDefinitionsIdPathMatcher => new($"^{_prefixEscaped}\\/protodefinitions\\/.+$");
}
#region InitAdmin #region InitAdmin
private void InitAdmin() private void InitAdmin()
{ {
_adminPaths = new AdminPaths(_settings); _adminPaths = _options.AdminPaths;
// __admin/health // __admin/health
Given(Request.Create().WithPath(_adminPaths.Health).UsingGet()).AtPriority(WireMockConstants.AdminPriority).RespondWith(new DynamicResponseProvider(HealthGet)); Given(Request.Create().WithPath(_adminPaths.Health).UsingGet()).AtPriority(WireMockConstants.AdminPriority).RespondWith(new DynamicResponseProvider(HealthGet));
@@ -631,7 +596,7 @@ public partial class WireMockServer
{ {
if (TryParseGuidFromRequestMessage(requestMessage, out var guid)) if (TryParseGuidFromRequestMessage(requestMessage, out var guid))
{ {
var entry = LogEntries.SingleOrDefault(r => r.RequestMessage != null && !r.RequestMessage.Path.StartsWith("/__admin/") && r.Guid == guid); var entry = LogEntries.SingleOrDefault(r => r.RequestMessage != null && !_adminPaths!.Includes(r.RequestMessage.Path) && r.Guid == guid);
if (entry is { }) if (entry is { })
{ {
var model = new LogEntryMapper(_options).Map(entry); var model = new LogEntryMapper(_options).Map(entry);
@@ -660,7 +625,7 @@ public partial class WireMockServer
{ {
var logEntryMapper = new LogEntryMapper(_options); var logEntryMapper = new LogEntryMapper(_options);
var result = LogEntries var result = LogEntries
.Where(r => r.RequestMessage != null && !r.RequestMessage.Path.StartsWith("/__admin/")) .Where(r => r.RequestMessage != null && !_adminPaths!.Includes(r.RequestMessage.Path))
.Select(logEntryMapper.Map); .Select(logEntryMapper.Map);
return ToJson(result); return ToJson(result);
@@ -682,7 +647,7 @@ public partial class WireMockServer
var request = (Request)InitRequestBuilder(requestModel); var request = (Request)InitRequestBuilder(requestModel);
var dict = new Dictionary<ILogEntry, RequestMatchResult>(); var dict = new Dictionary<ILogEntry, RequestMatchResult>();
foreach (var logEntry in LogEntries.Where(le => le.RequestMessage != null && !le.RequestMessage.Path.StartsWith("/__admin/"))) foreach (var logEntry in LogEntries.Where(le => le.RequestMessage != null && !_adminPaths!.Includes(le.RequestMessage.Path)))
{ {
var requestMatchResult = new RequestMatchResult(); var requestMatchResult = new RequestMatchResult();
if (request.GetMatchingScore(logEntry.RequestMessage!, requestMatchResult) > MatchScores.AlmostPerfect) if (request.GetMatchingScore(logEntry.RequestMessage!, requestMatchResult) > MatchScores.AlmostPerfect)
@@ -704,7 +669,7 @@ public partial class WireMockServer
Guid.TryParse(value.ToString(), out var mappingGuid) Guid.TryParse(value.ToString(), out var mappingGuid)
) )
{ {
var logEntries = LogEntries.Where(le => le.RequestMessage != null && !le.RequestMessage.Path.StartsWith("/__admin/") && le.MappingGuid == mappingGuid); var logEntries = LogEntries.Where(le => le.RequestMessage != null && !_adminPaths!.Includes(le.RequestMessage.Path) && le.MappingGuid == mappingGuid);
var logEntryMapper = new LogEntryMapper(_options); var logEntryMapper = new LogEntryMapper(_options);
var result = logEntries.Select(logEntryMapper.Map); var result = logEntries.Select(logEntryMapper.Map);
return ToJson(result); return ToJson(result);
@@ -50,6 +50,9 @@ public class WireMockMiddlewareTests
_guidUtilsMock = new Mock<IGuidUtils>(); _guidUtilsMock = new Mock<IGuidUtils>();
_guidUtilsMock.Setup(g => g.NewGuid()).Returns(NewGuid); _guidUtilsMock.Setup(g => g.NewGuid()).Returns(NewGuid);
var adminPathsMock = new Mock<IAdminPaths>();
adminPathsMock.Setup(a => a.Includes(It.IsAny<string?>())).Returns((string? path) => path?.StartsWith("/__admin/") == true);
_dateTimeUtilsMock = new Mock<IDateTimeUtils>(); _dateTimeUtilsMock = new Mock<IDateTimeUtils>();
_dateTimeUtilsMock.Setup(d => d.UtcNow).Returns(UtcNow); _dateTimeUtilsMock.Setup(d => d.UtcNow).Returns(UtcNow);
@@ -89,6 +92,7 @@ public class WireMockMiddlewareTests
_sut = new WireMockMiddleware( _sut = new WireMockMiddleware(
_ => Task.CompletedTask, _ => Task.CompletedTask,
_optionsMock.Object, _optionsMock.Object,
adminPathsMock.Object,
_requestMapperMock.Object, _requestMapperMock.Object,
_responseMapperMock.Object, _responseMapperMock.Object,
_matcherMock.Object, _matcherMock.Object,