diff --git a/src/WireMock.Net.Abstractions/Admin/Mappings/MappingModel.cs b/src/WireMock.Net.Abstractions/Admin/Mappings/MappingModel.cs index a6c7ce1c9..75c4bb957 100644 --- a/src/WireMock.Net.Abstractions/Admin/Mappings/MappingModel.cs +++ b/src/WireMock.Net.Abstractions/Admin/Mappings/MappingModel.cs @@ -55,12 +55,17 @@ public class MappingModel /// In case the value is null state will not be changed. /// public string? SetStateTo { get; set; } - + /// /// The number of times this match should be matched before the state will be changed to the specified one. /// public int? TimesInSameState { get; set; } + /// + /// Value to determine if the mapping is disabled. Defaults to null (not disabled). + /// + public bool? IsDisabled { get; set; } + /// /// The request model. /// @@ -100,7 +105,7 @@ public class MappingModel /// public object? Data { get; set; } - /// + /// /// The probability when this request should be matched. Value is between 0 and 1. [Optional] /// public double? Probability { get; set; } diff --git a/src/WireMock.Net.Minimal/Mapping.cs b/src/WireMock.Net.Minimal/Mapping.cs index 181c7fa99..b4441ce99 100644 --- a/src/WireMock.Net.Minimal/Mapping.cs +++ b/src/WireMock.Net.Minimal/Mapping.cs @@ -62,6 +62,9 @@ public class Mapping : IMapping /// public bool IsProxy => Provider is ProxyAsyncResponseProvider; + /// + public bool IsDisabled { get; set; } + /// public bool LogMapping => Provider is not (DynamicResponseProvider or DynamicAsyncResponseProvider); diff --git a/src/WireMock.Net.Minimal/Owin/MappingMatcher.cs b/src/WireMock.Net.Minimal/Owin/MappingMatcher.cs index 0750afce2..97d1fc58b 100644 --- a/src/WireMock.Net.Minimal/Owin/MappingMatcher.cs +++ b/src/WireMock.Net.Minimal/Owin/MappingMatcher.cs @@ -19,6 +19,7 @@ internal class MappingMatcher(IWireMockMiddlewareOptions options, IRandomizerDou var possibleMappings = new List(); var mappings = _options.Mappings.Values + .Where(m => !m.IsDisabled) .Where(m => m.TimeSettings.IsValid()) .Where(m => m.Probability is null || _randomizerDoubleBetween0And1.Generate() <= m.Probability) .ToArray(); diff --git a/src/WireMock.Net.Minimal/Serialization/MappingConverter.cs b/src/WireMock.Net.Minimal/Serialization/MappingConverter.cs index 90b3d246d..f26c4ddfb 100644 --- a/src/WireMock.Net.Minimal/Serialization/MappingConverter.cs +++ b/src/WireMock.Net.Minimal/Serialization/MappingConverter.cs @@ -275,6 +275,7 @@ public MappingModel ToMappingModel(IMapping mapping) TimesInSameState = !string.IsNullOrWhiteSpace(mapping.NextState) ? mapping.TimesInSameState : null, Data = mapping.Data, Probability = mapping.Probability, + IsDisabled = mapping.IsDisabled ? true : null, Request = new RequestModel { Headers = headerMatchers.Any() ? headerMatchers.Select(hm => new HeaderModel diff --git a/src/WireMock.Net.Minimal/Server/IRespondWithAProvider.cs b/src/WireMock.Net.Minimal/Server/IRespondWithAProvider.cs index a26e45240..c76696f3c 100644 --- a/src/WireMock.Net.Minimal/Server/IRespondWithAProvider.cs +++ b/src/WireMock.Net.Minimal/Server/IRespondWithAProvider.cs @@ -234,6 +234,13 @@ IRespondWithAProvider WithWebhook( /// The . IRespondWithAProvider WithProbability(double probability); + /// + /// Define whether this mapping is disabled. Defaults to false. + /// + /// Whether this mapping is disabled. + /// The . + IRespondWithAProvider WithIsDisabled(bool isDisabled); + /// /// Define a Grpc ProtoDefinition which is used for the request and the response. /// This can be a ProtoDefinition as a string, or an id when the ProtoDefinitions are defined at the WireMockServer. diff --git a/src/WireMock.Net.Minimal/Server/RespondWithAProvider.cs b/src/WireMock.Net.Minimal/Server/RespondWithAProvider.cs index 65422f6cc..499caf668 100644 --- a/src/WireMock.Net.Minimal/Server/RespondWithAProvider.cs +++ b/src/WireMock.Net.Minimal/Server/RespondWithAProvider.cs @@ -37,6 +37,7 @@ internal class RespondWithAProvider : IRespondWithAProvider private int _timesInSameState = 1; private bool? _useWebhookFireAndForget; private double? _probability; + private bool _isDisabled = false; private GraphQLSchemaDetails? _graphQLSchemaDetails; // Future Use. public Guid Guid { get; private set; } @@ -108,6 +109,11 @@ public void RespondWith(IResponseProvider provider) mapping.WithProbability(_probability.Value); } + if (_isDisabled) + { + mapping.IsDisabled = true; + } + if (ProtoDefinition != null) { mapping.WithProtoDefinition(ProtoDefinition.Value); @@ -354,6 +360,13 @@ public IRespondWithAProvider WithProbability(double probability) return this; } + /// + public IRespondWithAProvider WithIsDisabled(bool isDisabled) + { + _isDisabled = isDisabled; + return this; + } + /// public IRespondWithAProvider WithProtoDefinition(params string[] protoDefinitionOrId) { diff --git a/src/WireMock.Net.Minimal/Server/WireMockServer.Admin.cs b/src/WireMock.Net.Minimal/Server/WireMockServer.Admin.cs index 80e12db65..48a4990c1 100644 --- a/src/WireMock.Net.Minimal/Server/WireMockServer.Admin.cs +++ b/src/WireMock.Net.Minimal/Server/WireMockServer.Admin.cs @@ -57,6 +57,8 @@ public AdminPaths(WireMockServerSettings settings) 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\\/.+$"); @@ -100,6 +102,12 @@ private void InitAdmin() Given(Request.Create().WithPath(_adminPaths.MappingsGuidPathMatcher).UsingPut().WithHeader(HttpKnownHeaderNames.ContentType, AdminRequestContentTypeJson)).AtPriority(WireMockConstants.AdminPriority).RespondWith(new DynamicResponseProvider(MappingPut)); Given(Request.Create().WithPath(_adminPaths.MappingsGuidPathMatcher).UsingDelete()).AtPriority(WireMockConstants.AdminPriority).RespondWith(new DynamicResponseProvider(MappingDelete)); + // __admin/mappings/{guid}/enable + Given(Request.Create().WithPath(_adminPaths.MappingsGuidEnablePathMatcher).UsingPut()).AtPriority(WireMockConstants.AdminPriority).RespondWith(new DynamicResponseProvider(MappingEnable)); + + // __admin/mappings/{guid}/disable + Given(Request.Create().WithPath(_adminPaths.MappingsGuidDisablePathMatcher).UsingPut()).AtPriority(WireMockConstants.AdminPriority).RespondWith(new DynamicResponseProvider(MappingDisable)); + // __admin/mappings/code/{guid} Given(Request.Create().WithPath(_adminPaths.MappingsCodeGuidPathMatcher).UsingGet()).AtPriority(WireMockConstants.AdminPriority).RespondWith(new DynamicResponseProvider(MappingCodeGet)); @@ -426,6 +434,47 @@ private static bool TryParseGuidFromRequestMessage(IRequestMessage requestMessag var lastPart = requestMessage.Path.Split('/').LastOrDefault(); return Guid.TryParse(lastPart, out guid); } + + private static bool TryParseGuidFromSecondToLastSegment(IRequestMessage requestMessage, out Guid guid) + { + var parts = requestMessage.Path.Split('/'); + if (parts.Length >= 2 && Guid.TryParse(parts[parts.Length - 2], out guid)) + return true; + guid = Guid.Empty; + return false; + } + + private IResponseMessage MappingEnable(HttpContext _, IRequestMessage requestMessage) + { + if (TryParseGuidFromSecondToLastSegment(requestMessage, out var guid)) + { + var mapping = Mappings.FirstOrDefault(m => !m.IsAdminInterface && m.Guid == guid); + if (mapping != null) + { + mapping.IsDisabled = false; + return ResponseMessageBuilder.Create(HttpStatusCode.OK, "Mapping enabled", guid); + } + } + + _settings.Logger.Warn("HttpStatusCode set to 404 : Mapping not found"); + return ResponseMessageBuilder.Create(HttpStatusCode.NotFound, "Mapping not found"); + } + + private IResponseMessage MappingDisable(HttpContext _, IRequestMessage requestMessage) + { + if (TryParseGuidFromSecondToLastSegment(requestMessage, out var guid)) + { + var mapping = Mappings.FirstOrDefault(m => !m.IsAdminInterface && m.Guid == guid); + if (mapping != null) + { + mapping.IsDisabled = true; + return ResponseMessageBuilder.Create(HttpStatusCode.OK, "Mapping disabled", guid); + } + } + + _settings.Logger.Warn("HttpStatusCode set to 404 : Mapping not found"); + return ResponseMessageBuilder.Create(HttpStatusCode.NotFound, "Mapping not found"); + } #endregion Mapping/{guid} #region Mappings diff --git a/src/WireMock.Net.Minimal/Server/WireMockServer.ConvertMapping.cs b/src/WireMock.Net.Minimal/Server/WireMockServer.ConvertMapping.cs index 4cff565dc..fa27c1a2e 100644 --- a/src/WireMock.Net.Minimal/Server/WireMockServer.ConvertMapping.cs +++ b/src/WireMock.Net.Minimal/Server/WireMockServer.ConvertMapping.cs @@ -120,6 +120,11 @@ private Guid ConvertMappingAndRegisterAsRespondProvider(MappingModel mappingMode respondProvider.WithProbability(mappingModel.Probability.Value); } + if (mappingModel.IsDisabled == true) + { + respondProvider.WithIsDisabled(true); + } + // ProtoDefinition is defined at Mapping level if (mappingModel.ProtoDefinition != null) { diff --git a/src/WireMock.Net.RestClient/IWireMockAdminApi.cs b/src/WireMock.Net.RestClient/IWireMockAdminApi.cs index ee265c0fe..82fd1cb86 100644 --- a/src/WireMock.Net.RestClient/IWireMockAdminApi.cs +++ b/src/WireMock.Net.RestClient/IWireMockAdminApi.cs @@ -163,6 +163,22 @@ public interface IWireMockAdminApi [Header("Content-Type", "application/json")] Task PutMappingAsync([Path] Guid guid, [Body] MappingModel mapping, CancellationToken cancellationToken = default); + /// + /// Enable a mapping based on the guid. + /// + /// The Guid. + /// The optional cancellationToken. + [Put("mappings/{guid}/enable")] + Task EnableMappingAsync([Path] Guid guid, CancellationToken cancellationToken = default); + + /// + /// Disable a mapping based on the guid. + /// + /// The Guid. + /// The optional cancellationToken. + [Put("mappings/{guid}/disable")] + Task DisableMappingAsync([Path] Guid guid, CancellationToken cancellationToken = default); + /// /// Delete a mapping based on the guid /// diff --git a/src/WireMock.Net.Shared/IMapping.cs b/src/WireMock.Net.Shared/IMapping.cs index 498638497..ee2f3dacf 100644 --- a/src/WireMock.Net.Shared/IMapping.cs +++ b/src/WireMock.Net.Shared/IMapping.cs @@ -108,6 +108,14 @@ public interface IMapping /// bool IsProxy { get; } + /// + /// Gets a value indicating whether this mapping is disabled. + /// + /// + /// true if this mapping is disabled; otherwise, false. + /// + bool IsDisabled { get; set; } + /// /// Gets a value indicating whether this mapping to be logged. /// @@ -135,7 +143,7 @@ public interface IMapping /// object? Data { get; } - /// + /// /// The probability when this request should be matched. Value is between 0 and 1. [Optional] /// double? Probability { get; } diff --git a/test/WireMock.Net.Tests/AdminApi/WireMockAdminApiTests.IsDisabled.cs b/test/WireMock.Net.Tests/AdminApi/WireMockAdminApiTests.IsDisabled.cs new file mode 100644 index 000000000..4d247d303 --- /dev/null +++ b/test/WireMock.Net.Tests/AdminApi/WireMockAdminApiTests.IsDisabled.cs @@ -0,0 +1,134 @@ +// Copyright © WireMock.Net + +using RestEase; +using WireMock.Admin.Mappings; +using WireMock.Client; +using WireMock.Server; + +namespace WireMock.Net.Tests.AdminApi; + +public partial class WireMockAdminApiTests +{ + [Fact] + public async Task IWireMockAdminApi_PostMappingAsync_WithIsDisabledTrue_DoesNotMatchRequests() + { + // Arrange + var ct = TestContext.Current.CancellationToken; + using var server = WireMockServer.StartWithAdminInterface(); + var api = RestClient.For(server.Urls[0]); + var httpClient = server.CreateClient(); + + var model = new MappingModel + { + Request = new RequestModel { Path = "/foo", Methods = ["GET"] }, + Response = new ResponseModel { Body = "hello", StatusCode = 200 }, + IsDisabled = true + }; + + // Act — POST the disabled mapping + var postResult = await api.PostMappingAsync(model, ct); + postResult.Should().NotBeNull(); + + // Assert — request should not be matched (404) + var response = await httpClient.GetAsync("/foo", ct); + ((int)response.StatusCode).Should().Be(404); + + // Assert — mapping exists but IsDisabled is true + server.Mappings.Where(m => !m.IsAdminInterface).Should().ContainSingle(m => m.IsDisabled == true); + } + + [Fact] + public async Task IWireMockAdminApi_DisableMappingAsync_PreventsMatching() + { + // Arrange + var ct = TestContext.Current.CancellationToken; + using var server = WireMockServer.StartWithAdminInterface(); + var api = RestClient.For(server.Urls[0]); + var httpClient = server.CreateClient(); + + var model = new MappingModel + { + Request = new RequestModel { Path = "/bar", Methods = ["GET"] }, + Response = new ResponseModel { Body = "world", StatusCode = 200 } + }; + var postResult = await api.PostMappingAsync(model, ct); + var guid = postResult.Guid!.Value; + + // Assert — mapping matches before disable + var before = await httpClient.GetAsync("/bar", ct); + ((int)before.StatusCode).Should().Be(200); + + // Act — disable + var disableResult = await api.DisableMappingAsync(guid, ct); + disableResult.Status.Should().Be("Mapping disabled"); + + // Assert — no match after disable + var after = await httpClient.GetAsync("/bar", ct); + ((int)after.StatusCode).Should().Be(404); + } + + [Fact] + public async Task IWireMockAdminApi_EnableMappingAsync_ResumesMatching() + { + // Arrange + var ct = TestContext.Current.CancellationToken; + using var server = WireMockServer.StartWithAdminInterface(); + var api = RestClient.For(server.Urls[0]); + var httpClient = server.CreateClient(); + + var model = new MappingModel + { + Request = new RequestModel { Path = "/baz", Methods = ["GET"] }, + Response = new ResponseModel { Body = "re-enabled", StatusCode = 200 }, + IsDisabled = true + }; + var postResult = await api.PostMappingAsync(model, ct); + var guid = postResult.Guid!.Value; + + // Assert — no match while disabled + var before = await httpClient.GetAsync("/baz", ct); + ((int)before.StatusCode).Should().Be(404); + + // Act — enable + var enableResult = await api.EnableMappingAsync(guid, ct); + enableResult.Status.Should().Be("Mapping enabled"); + + // Assert — mapping matches after enable + var after = await httpClient.GetAsync("/baz", ct); + ((int)after.StatusCode).Should().Be(200); + } + + [Fact] + public async Task IWireMockAdminApi_GetMappingAsync_ReturnsIsDisabledTrue_WhenDisabled() + { + // Arrange + var ct = TestContext.Current.CancellationToken; + using var server = WireMockServer.StartWithAdminInterface(); + var api = RestClient.For(server.Urls[0]); + + var disabledModel = new MappingModel + { + Request = new RequestModel { Path = "/check-disabled" }, + Response = new ResponseModel { Body = "x", StatusCode = 200 }, + IsDisabled = true + }; + var enabledModel = new MappingModel + { + Request = new RequestModel { Path = "/check-enabled" }, + Response = new ResponseModel { Body = "y", StatusCode = 200 } + }; + + var disabledPost = await api.PostMappingAsync(disabledModel, ct); + var enabledPost = await api.PostMappingAsync(enabledModel, ct); + + // Act + var disabledGot = await api.GetMappingAsync(disabledPost.Guid!.Value, ct); + var enabledGot = await api.GetMappingAsync(enabledPost.Guid!.Value, ct); + + // Assert — disabled mapping serializes IsDisabled = true + disabledGot.IsDisabled.Should().BeTrue(); + + // Assert — enabled mapping omits IsDisabled (null = default not disabled) + enabledGot.IsDisabled.Should().BeNull(); + } +} diff --git a/test/WireMock.Net.Tests/Constants.cs b/test/WireMock.Net.Tests/Constants.cs index 30e4cdc7c..aadd6fb9e 100644 --- a/test/WireMock.Net.Tests/Constants.cs +++ b/test/WireMock.Net.Tests/Constants.cs @@ -6,5 +6,5 @@ internal static class Constants { internal const int NumStaticMappings = 10; - internal const int NumAdminMappings = 37; + internal const int NumAdminMappings = 39; } \ No newline at end of file diff --git a/test/WireMock.Net.Tests/Owin/MappingMatcherTests.cs b/test/WireMock.Net.Tests/Owin/MappingMatcherTests.cs index 945d10d23..17da566c3 100644 --- a/test/WireMock.Net.Tests/Owin/MappingMatcherTests.cs +++ b/test/WireMock.Net.Tests/Owin/MappingMatcherTests.cs @@ -56,6 +56,7 @@ public void MappingMatcher_FindBestMatch_WhenMappingThrowsException_ShouldReturn { // Assign var mappingMock = new Mock(); + mappingMock.SetupGet(m => m.IsDisabled).Returns(false); mappingMock.Setup(m => m.GetRequestMatchResult(It.IsAny(), It.IsAny())).Throws(); var mappings = new ConcurrentDictionary(); @@ -229,6 +230,35 @@ public void MappingMatcher_FindBestMatch_WhenProbabilityDoesMatch_ShouldReturnPr result.Match!.Mapping.Guid.Should().Be(withProbability); } + [Fact] + public void MappingMatcher_FindBestMatch_WhenMappingIsDisabled_ShouldReturnNull() + { + // Assign + var guid = Guid.Parse("00000000-0000-0000-0000-000000000001"); + var mappingMock = new Mock(); + mappingMock.SetupGet(m => m.Guid).Returns(guid); + mappingMock.SetupGet(m => m.IsDisabled).Returns(true); + mappingMock.SetupGet(m => m.Probability).Returns((double?)null); + + var matchResult = new RequestMatchResult(); + matchResult.AddScore(typeof(object), 1.0, null); + mappingMock.Setup(m => m.GetRequestMatchResult(It.IsAny(), It.IsAny())).Returns(matchResult); + + var mappings = new ConcurrentDictionary(); + mappings.TryAdd(guid, mappingMock.Object); + _optionsMock.Setup(o => o.Mappings).Returns(mappings); + + var request = new RequestMessage(new UrlDetails("http://localhost/foo"), "GET", "::1"); + + // Act + var result = _sut.FindBestMatch(request); + + // Assert + result.Match.Should().BeNull(); + result.Partial.Should().BeNull(); + mappingMock.Verify(m => m.GetRequestMatchResult(It.IsAny(), It.IsAny()), Times.Never); + } + private static ConcurrentDictionary InitMappings(params (Guid guid, double[] scores, double? probability)[] matches) { var mappings = new ConcurrentDictionary(); @@ -237,6 +267,7 @@ private static ConcurrentDictionary InitMappings(params (Guid gu { var mappingMock = new Mock(); mappingMock.SetupGet(m => m.Guid).Returns(match.guid); + mappingMock.SetupGet(m => m.IsDisabled).Returns(false); var requestMatchResult = new RequestMatchResult(); foreach (var score in match.scores)