diff --git a/README.md b/README.md index efe1959..54a80ab 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,38 @@ curl -k -X POST "https://localhost:60101/api/events?api-version=2018-01-01" \ -d '[{"id":"1","subject":"/test","eventType":"Test","eventTime":"2024-01-01T00:00:00Z","data":{"message":"Hello"},"dataVersion":"1"}]' ``` +## Runtime subscription management (ARM control plane) + +By default, subscribers are configured at boot via `appsettings.json`. To also create and remove +event subscriptions **while the simulator is running** — exactly as an app does in Azure — set a +`managementPort`: + +```json +{ + "managementPort": 60100, + "topics": [ { "name": "MyTopic", "port": 60101, "key": "TheLocal+DevelopmentKey=" } ] +} +``` + +The simulator then exposes an ARM control-plane facade on that port that speaks the same HTTP as the +`Azure.ResourceManager.EventGrid` client. Point the real client at the simulator and create/get/ +list/delete topic-scoped WebHook subscriptions unmodified: + +```csharp +var options = new ArmClientOptions +{ + Environment = new ArmEnvironment(new Uri("https://localhost:60100/"), "https://management.azure.com"), +}; +var arm = new ArmClient(credential, subscriptionId, options); // any token; the simulator does not validate it +var topic = arm.GetEventGridTopicResource(new ResourceIdentifier(topicResourceId)); +await topic.GetTopicEventSubscriptions() + .CreateOrUpdateAsync(WaitUntil.Completed, "my-sub", data); +``` + +Runtime subscriptions are held in memory (seeded from `appsettings.json` at boot) and reset on +restart. Created WebHook subscriptions go through the normal validation handshake before they start +receiving events. Only WebHook destinations are supported today. + ## Dashboard Access the built-in dashboard at `https://localhost:/dashboard` to view event history and delivery status. diff --git a/src/AzureEventGridSimulator.Tests/ActualSimulatorTests/AzureResourceManagerEventGridTest.cs b/src/AzureEventGridSimulator.Tests/ActualSimulatorTests/AzureResourceManagerEventGridTest.cs new file mode 100644 index 0000000..c05b539 --- /dev/null +++ b/src/AzureEventGridSimulator.Tests/ActualSimulatorTests/AzureResourceManagerEventGridTest.cs @@ -0,0 +1,124 @@ +using Azure; +using Azure.Core; +using Azure.Core.Pipeline; +using Azure.ResourceManager; +using Azure.ResourceManager.EventGrid; +using Azure.ResourceManager.EventGrid.Models; +using AzureEventGridSimulator.Tests.Helpers; +using Shouldly; +using Xunit; + +namespace AzureEventGridSimulator.Tests.ActualSimulatorTests; + +/// +/// Verifies that the real Azure.ResourceManager.EventGrid (ARM management) client can manage +/// event subscriptions against the simulator at runtime, simply by repointing the client at the +/// simulator's management port. This is the trust anchor: the same NuGet library the Backend +/// uses must work unmodified against the simulator. +/// +[Collection(nameof(ActualSimulatorFixtureCollection))] +[Trait("Category", "integration-actual")] +public class AzureResourceManagerEventGridTest +{ + private const string ManagementEndpoint = "https://localhost:60100/"; + private const string SubscriptionId = "00000000-0000-0000-0000-000000000000"; + + private const string TopicResourceId = + $"/subscriptions/{SubscriptionId}/resourceGroups/aegs/providers/Microsoft.EventGrid/topics/ManagementTopic"; + + private static TopicEventSubscriptionCollection CreateSubscriptionCollection() + { + var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = + HttpClientHandler.DangerousAcceptAnyServerCertificateValidator, + }; + + var options = new ArmClientOptions + { + Environment = new ArmEnvironment( + new Uri(ManagementEndpoint), + "https://management.azure.com" + ), + Transport = new HttpClientTransport(handler), + }; + options.Retry.MaxRetries = 0; + + var armClient = new ArmClient(new FakeTokenCredential(), SubscriptionId, options); + var topic = armClient.GetEventGridTopicResource(new ResourceIdentifier(TopicResourceId)); + return topic.GetTopicEventSubscriptions(); + } + + private static EventGridSubscriptionData WebHookSubscription(string endpoint, string eventType) + { + var data = new EventGridSubscriptionData + { + Destination = new WebHookEventSubscriptionDestination { Endpoint = new Uri(endpoint) }, + Filter = new EventSubscriptionFilter(), + }; + data.Filter.IncludedEventTypes.Add(eventType); + return data; + } + + [Fact] + public async Task GivenWebHookSubscription_WhenCreated_ThenItCanBeFetchedWithItsProperties() + { + var subscriptions = CreateSubscriptionCollection(); + const string name = "created-sub"; + const string endpoint = "https://runtime-sink.test/created"; + + var created = await subscriptions.CreateOrUpdateAsync( + WaitUntil.Completed, + name, + WebHookSubscription(endpoint, "Runtime.Created") + ); + + created.Value.Data.Name.ShouldBe(name); + + var fetched = await subscriptions.GetAsync(name); + var destination = + fetched.Value.Data.Destination.ShouldBeOfType(); + destination.Endpoint.ShouldBe(new Uri(endpoint)); + fetched.Value.Data.Filter.IncludedEventTypes.ShouldContain("Runtime.Created"); + } + + [Fact] + public async Task GivenCreatedSubscription_WhenListing_ThenItIsReturned() + { + var subscriptions = CreateSubscriptionCollection(); + const string name = "listed-sub"; + + await subscriptions.CreateOrUpdateAsync( + WaitUntil.Completed, + name, + WebHookSubscription("https://runtime-sink.test/listed", "Runtime.Listed") + ); + + var names = new List(); + await foreach (var subscription in subscriptions.GetAllAsync()) + { + names.Add(subscription.Data.Name); + } + + names.ShouldContain(name); + } + + [Fact] + public async Task GivenCreatedSubscription_WhenDeleted_ThenItNoLongerExists() + { + var subscriptions = CreateSubscriptionCollection(); + const string name = "deleted-sub"; + + await subscriptions.CreateOrUpdateAsync( + WaitUntil.Completed, + name, + WebHookSubscription("https://runtime-sink.test/deleted", "Runtime.Deleted") + ); + + var subscription = await subscriptions.GetAsync(name); + await subscription.Value.DeleteAsync(WaitUntil.Completed); + + var exists = await subscriptions.GetIfExistsAsync(name); + exists.HasValue.ShouldBeFalse(); + } +} diff --git a/src/AzureEventGridSimulator.Tests/ActualSimulatorTests/RuntimeSubscriptionDeliveryTest.cs b/src/AzureEventGridSimulator.Tests/ActualSimulatorTests/RuntimeSubscriptionDeliveryTest.cs new file mode 100644 index 0000000..5f84b52 --- /dev/null +++ b/src/AzureEventGridSimulator.Tests/ActualSimulatorTests/RuntimeSubscriptionDeliveryTest.cs @@ -0,0 +1,141 @@ +using System.Diagnostics; +using Azure; +using Azure.Core; +using Azure.Core.Pipeline; +using Azure.Messaging.EventGrid; +using Azure.ResourceManager; +using Azure.ResourceManager.EventGrid; +using Azure.ResourceManager.EventGrid.Models; +using AzureEventGridSimulator.Tests.Helpers; +using Shouldly; +using Xunit; + +namespace AzureEventGridSimulator.Tests.ActualSimulatorTests; + +/// +/// End-to-end proof that a subscription created at runtime through the real ARM client actually +/// participates in delivery: an event published to the topic is delivered to the runtime-created +/// webhook, and delivery stops once the subscription is deleted. +/// +[Collection(nameof(ActualSimulatorFixtureCollection))] +[Trait("Category", "integration-actual")] +public class RuntimeSubscriptionDeliveryTest +{ + private const string TopicName = "RuntimeDeliveryTopic"; + private const int TopicPort = 60104; + private const int SinkPort = 60110; + private const string EventType = "Delivery.RuntimeTest"; + private const string SubscriptionId = "00000000-0000-0000-0000-000000000000"; + + private static readonly string TopicResourceId = + $"/subscriptions/{SubscriptionId}/resourceGroups/aegs" + + $"/providers/Microsoft.EventGrid/topics/{TopicName}"; + + [Fact] + public async Task GivenRuntimeWebHookSubscription_WhenEventPublished_ThenItIsDeliveredUntilDeleted() + { + await using var sink = WebhookSink.Start(SinkPort); + var subscriptions = CreateSubscriptionCollection(); + const string name = "delivery-sub"; + + var data = new EventGridSubscriptionData + { + Destination = new WebHookEventSubscriptionDestination + { + Endpoint = new Uri(sink.Endpoint), + }, + Filter = new EventSubscriptionFilter(), + }; + data.Filter.IncludedEventTypes.Add(EventType); + + await subscriptions.CreateOrUpdateAsync(WaitUntil.Completed, name, data); + + var publisher = CreatePublisherClient(); + await publisher.SendEventAsync( + new EventGridEvent("/runtime", EventType, "v1", new { hello = "world" }) + ); + + await WaitForAsync( + () => sink.ReceivedEvents.Count >= 1, + TimeSpan.FromSeconds(15), + "event to be delivered to the runtime-created subscription" + ); + + // Delete the subscription, then publish again; the event must not be delivered. + var subscription = await subscriptions.GetAsync(name); + await subscription.Value.DeleteAsync(WaitUntil.Completed); + + await publisher.SendEventAsync( + new EventGridEvent("/runtime", EventType, "v1", new { hello = "again" }) + ); + await Task.Delay(TimeSpan.FromSeconds(3)); + + sink.ReceivedEvents.Count.ShouldBe(1); + } + + private static TopicEventSubscriptionCollection CreateSubscriptionCollection() + { + var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = + HttpClientHandler.DangerousAcceptAnyServerCertificateValidator, + }; + + var options = new ArmClientOptions + { + Environment = new ArmEnvironment( + new Uri("https://localhost:60100/"), + "https://management.azure.com" + ), + Transport = new HttpClientTransport(handler), + }; + options.Retry.MaxRetries = 0; + + var armClient = new ArmClient(new FakeTokenCredential(), SubscriptionId, options); + return armClient + .GetEventGridTopicResource(new ResourceIdentifier(TopicResourceId)) + .GetTopicEventSubscriptions(); + } + + private static EventGridPublisherClient CreatePublisherClient() + { + var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = + HttpClientHandler.DangerousAcceptAnyServerCertificateValidator, + }; + + var options = new EventGridPublisherClientOptions + { + Transport = new HttpClientTransport(handler), + Retry = + { + Mode = RetryMode.Fixed, + MaxRetries = 0, + NetworkTimeout = TimeSpan.FromSeconds(5), + }, + }; + + return new EventGridPublisherClient( + new Uri($"https://localhost:{TopicPort}/api/events"), + new AzureKeyCredential("TheLocal+DevelopmentKey="), + options + ); + } + + private static async Task WaitForAsync(Func condition, TimeSpan timeout, string because) + { + var stopwatch = Stopwatch.StartNew(); + while (stopwatch.Elapsed < timeout) + { + if (condition()) + { + return; + } + + await Task.Delay(100); + } + + throw new TimeoutException($"Timed out waiting for {because}."); + } +} diff --git a/src/AzureEventGridSimulator.Tests/AzureEventGridSimulator.Tests.csproj b/src/AzureEventGridSimulator.Tests/AzureEventGridSimulator.Tests.csproj index d83b428..0f947f2 100644 --- a/src/AzureEventGridSimulator.Tests/AzureEventGridSimulator.Tests.csproj +++ b/src/AzureEventGridSimulator.Tests/AzureEventGridSimulator.Tests.csproj @@ -6,6 +6,7 @@ + diff --git a/src/AzureEventGridSimulator.Tests/Helpers/FakeTokenCredential.cs b/src/AzureEventGridSimulator.Tests/Helpers/FakeTokenCredential.cs new file mode 100644 index 0000000..39a8505 --- /dev/null +++ b/src/AzureEventGridSimulator.Tests/Helpers/FakeTokenCredential.cs @@ -0,0 +1,23 @@ +using Azure.Core; + +namespace AzureEventGridSimulator.Tests.Helpers; + +/// +/// A credential that returns a static, non-validated token. The simulator's management API +/// accepts any bearer token, so this lets the real ARM client authenticate without Azure AD +/// (i.e. without requiring 'az login') in tests and local development. +/// +public class FakeTokenCredential : TokenCredential +{ + private static readonly AccessToken Token = new("fake-token", DateTimeOffset.MaxValue); + + public override AccessToken GetToken( + TokenRequestContext requestContext, + CancellationToken ct + ) => Token; + + public override ValueTask GetTokenAsync( + TokenRequestContext requestContext, + CancellationToken ct + ) => new(Token); +} diff --git a/src/AzureEventGridSimulator.Tests/Helpers/WebhookSink.cs b/src/AzureEventGridSimulator.Tests/Helpers/WebhookSink.cs new file mode 100644 index 0000000..9d5047a --- /dev/null +++ b/src/AzureEventGridSimulator.Tests/Helpers/WebhookSink.cs @@ -0,0 +1,117 @@ +using System.Net; +using System.Text; +using System.Text.Json; + +namespace AzureEventGridSimulator.Tests.Helpers; + +/// +/// A minimal in-process webhook endpoint used by integration tests. It automatically completes +/// the Event Grid subscription-validation handshake (echoing the validation code back) and +/// records the bodies of any events delivered to it. +/// +public sealed class WebhookSink : IAsyncDisposable +{ + private readonly HttpListener _listener; + private readonly CancellationTokenSource _cts = new(); + private readonly Task _acceptLoop; + private readonly List _receivedEvents = []; + + private WebhookSink(int port) + { + Endpoint = $"http://localhost:{port}/"; + _listener = new HttpListener(); + _listener.Prefixes.Add(Endpoint); + _listener.Start(); + _acceptLoop = Task.Run(AcceptLoopAsync); + } + + public string Endpoint { get; } + + public IReadOnlyList ReceivedEvents + { + get + { + lock (_receivedEvents) + { + return _receivedEvents.ToArray(); + } + } + } + + public static WebhookSink Start(int port) => new(port); + + private async Task AcceptLoopAsync() + { + while (!_cts.IsCancellationRequested) + { + HttpListenerContext context; + try + { + context = await _listener.GetContextAsync(); + } + catch + { + break; + } + + using var reader = new StreamReader( + context.Request.InputStream, + context.Request.ContentEncoding + ); + var body = await reader.ReadToEndAsync(); + + var eventType = context.Request.Headers["aeg-event-type"]; + var responseText = ""; + + if ( + string.Equals( + eventType, + "SubscriptionValidation", + StringComparison.OrdinalIgnoreCase + ) + ) + { + responseText = $"{{\"validationResponse\":\"{ExtractValidationCode(body)}\"}}"; + } + else + { + lock (_receivedEvents) + { + _receivedEvents.Add(body); + } + } + + var buffer = Encoding.UTF8.GetBytes(responseText); + context.Response.ContentType = "application/json"; + context.Response.StatusCode = 200; + await context.Response.OutputStream.WriteAsync(buffer); + context.Response.Close(); + } + } + + private static string ExtractValidationCode(string body) + { + using var document = JsonDocument.Parse(body); + var root = document.RootElement; + var evt = root.ValueKind == JsonValueKind.Array ? root[0] : root; + return evt.GetProperty("data").GetProperty("validationCode").GetString() ?? ""; + } + + public async ValueTask DisposeAsync() + { + await _cts.CancelAsync(); + _listener.Stop(); + _listener.Close(); + + try + { + await _acceptLoop; + } + catch + { + // Ignore shutdown races. + } + + _cts.Dispose(); + } +} diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Management/EventSubscriptionMapperTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Management/EventSubscriptionMapperTests.cs new file mode 100644 index 0000000..c419f5b --- /dev/null +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Management/EventSubscriptionMapperTests.cs @@ -0,0 +1,246 @@ +using System.Text.Json; +using AzureEventGridSimulator.Domain.Entities.Management; +using AzureEventGridSimulator.Domain.Services.Management; +using AzureEventGridSimulator.Infrastructure.Settings; +using AzureEventGridSimulator.Infrastructure.Settings.Subscribers; +using Shouldly; +using Xunit; + +namespace AzureEventGridSimulator.Tests.UnitTests.Management; + +[Trait("Category", "unit")] +public class EventSubscriptionMapperTests +{ + private static readonly EventSubscriptionScope Scope = new("sub-id", "rg", "MyTopic"); + + [Fact] + public void Should_MapWebHookDestination_When_ConvertingFromArm() + { + var resource = new ArmEventSubscriptionResource + { + Properties = new ArmEventSubscriptionProperties + { + Destination = new ArmEventSubscriptionDestination + { + EndpointType = "WebHook", + Properties = new ArmEventSubscriptionDestinationProperties + { + EndpointUrl = "https://example.test/hook", + }, + }, + }, + }; + + var mapped = EventSubscriptionMapper.TryMapToHttpSubscriber( + "my-sub", + resource, + out var subscriber + ); + + mapped.ShouldBeTrue(); + subscriber!.Name.ShouldBe("my-sub"); + subscriber.Endpoint.ShouldBe("https://example.test/hook"); + } + + [Fact] + public void Should_MapIncludedEventTypesAndAdvancedFilter_When_ConvertingFromArm() + { + var resource = new ArmEventSubscriptionResource + { + Properties = new ArmEventSubscriptionProperties + { + Destination = new ArmEventSubscriptionDestination + { + EndpointType = "WebHook", + Properties = new ArmEventSubscriptionDestinationProperties + { + EndpointUrl = "https://example.test/hook", + }, + }, + Filter = new ArmEventSubscriptionFilter + { + IncludedEventTypes = ["candidate.created"], + AdvancedFilters = + [ + new ArmAdvancedFilter + { + OperatorType = "StringIn", + Key = "data.officeId", + Values = + [ + JsonSerializer.SerializeToElement("office-1"), + JsonSerializer.SerializeToElement("office-2"), + ], + }, + ], + }, + }, + }; + + EventSubscriptionMapper.TryMapToHttpSubscriber("my-sub", resource, out var subscriber); + + subscriber!.Filter!.IncludedEventTypes.ShouldBe(["candidate.created"]); + var advanced = subscriber.Filter.AdvancedFilters!.ShouldHaveSingleItem(); + advanced.OperatorType.ShouldBe(AdvancedFilterSetting.AdvancedFilterOperatorType.StringIn); + advanced.Key.ShouldBe("data.officeId"); + advanced.Values!.ShouldBe(["office-1", "office-2"]); + } + + [Fact] + public void Should_ReturnFalse_When_DestinationIsNotWebHook() + { + var resource = new ArmEventSubscriptionResource + { + Properties = new ArmEventSubscriptionProperties + { + Destination = new ArmEventSubscriptionDestination { EndpointType = "EventHub" }, + }, + }; + + EventSubscriptionMapper.TryMapToHttpSubscriber("my-sub", resource, out _).ShouldBeFalse(); + } + + [Fact] + public void Should_ProduceSucceededWebHookResource_When_ConvertingToArm() + { + var subscriber = new HttpSubscriberSettings + { + Name = "my-sub", + Endpoint = "https://example.test/hook", + Filter = new FilterSetting { IncludedEventTypes = ["candidate.created"] }, + }; + + var resource = EventSubscriptionMapper.MapToArm(Scope, subscriber); + + resource.Name.ShouldBe("my-sub"); + resource.Id.ShouldBe( + "/subscriptions/sub-id/resourceGroups/rg" + + "/providers/Microsoft.EventGrid/topics/MyTopic/eventSubscriptions/my-sub" + ); + resource.Properties!.ProvisioningState.ShouldBe("Succeeded"); + resource.Properties.Destination!.EndpointType.ShouldBe("WebHook"); + resource.Properties.Destination.Properties!.EndpointUrl.ShouldBe( + "https://example.test/hook" + ); + resource.Properties.Filter!.IncludedEventTypes.ShouldBe(["candidate.created"]); + } + + [Fact] + public void Should_MapStorageQueueDestination_When_ConvertingFromArm() + { + var resource = new ArmEventSubscriptionResource + { + Properties = new ArmEventSubscriptionProperties + { + Destination = new ArmEventSubscriptionDestination + { + EndpointType = "StorageQueue", + Properties = new ArmEventSubscriptionDestinationProperties + { + ResourceId = + "/subscriptions/sub-id/resourceGroups/rg/providers" + + "/Microsoft.Storage/storageAccounts/myaccount", + QueueName = "my-queue", + }, + }, + Filter = new ArmEventSubscriptionFilter + { + IncludedEventTypes = ["candidate.created"], + }, + }, + }; + + var mapped = EventSubscriptionMapper.TryMapToStorageQueueSubscriber( + "my-sub", + resource, + out var subscriber + ); + + mapped.ShouldBeTrue(); + subscriber!.Name.ShouldBe("my-sub"); + subscriber.QueueName.ShouldBe("my-queue"); + subscriber.SourceResourceId.ShouldBe( + "/subscriptions/sub-id/resourceGroups/rg/providers" + + "/Microsoft.Storage/storageAccounts/myaccount" + ); + subscriber.Filter!.IncludedEventTypes.ShouldBe(["candidate.created"]); + } + + [Fact] + public void Should_ReturnFalse_When_DestinationIsNotStorageQueue() + { + var resource = new ArmEventSubscriptionResource + { + Properties = new ArmEventSubscriptionProperties + { + Destination = new ArmEventSubscriptionDestination + { + EndpointType = "WebHook", + Properties = new ArmEventSubscriptionDestinationProperties + { + EndpointUrl = "https://example.test/hook", + }, + }, + }, + }; + + EventSubscriptionMapper + .TryMapToStorageQueueSubscriber("my-sub", resource, out _) + .ShouldBeFalse(); + } + + [Fact] + public void Should_ReturnFalse_When_StorageQueueDestinationHasNoQueueName() + { + var resource = new ArmEventSubscriptionResource + { + Properties = new ArmEventSubscriptionProperties + { + Destination = new ArmEventSubscriptionDestination + { + EndpointType = "StorageQueue", + Properties = new ArmEventSubscriptionDestinationProperties + { + ResourceId = + "/subscriptions/sub-id/resourceGroups/rg/providers" + + "/Microsoft.Storage/storageAccounts/myaccount", + }, + }, + }, + }; + + EventSubscriptionMapper + .TryMapToStorageQueueSubscriber("my-sub", resource, out _) + .ShouldBeFalse(); + } + + [Fact] + public void Should_ProduceSucceededStorageQueueResource_When_ConvertingToArm() + { + var subscriber = new StorageQueueSubscriberSettings + { + Name = "my-sub", + QueueName = "my-queue", + SourceResourceId = + "/subscriptions/sub-id/resourceGroups/rg/providers" + + "/Microsoft.Storage/storageAccounts/myaccount", + Filter = new FilterSetting { IncludedEventTypes = ["candidate.created"] }, + }; + + var resource = EventSubscriptionMapper.MapToArm(Scope, subscriber); + + resource.Name.ShouldBe("my-sub"); + resource.Id.ShouldBe( + "/subscriptions/sub-id/resourceGroups/rg" + + "/providers/Microsoft.EventGrid/topics/MyTopic/eventSubscriptions/my-sub" + ); + resource.Properties!.ProvisioningState.ShouldBe("Succeeded"); + resource.Properties.Destination!.EndpointType.ShouldBe("StorageQueue"); + resource.Properties.Destination.Properties!.QueueName.ShouldBe("my-queue"); + resource.Properties.Destination.Properties.ResourceId.ShouldBe( + "/subscriptions/sub-id/resourceGroups/rg/providers" + + "/Microsoft.Storage/storageAccounts/myaccount" + ); + resource.Properties.Filter!.IncludedEventTypes.ShouldBe(["candidate.created"]); + } +} diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Management/ManagementPortValidationTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Management/ManagementPortValidationTests.cs new file mode 100644 index 0000000..1cdf9bf --- /dev/null +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Management/ManagementPortValidationTests.cs @@ -0,0 +1,35 @@ +using AzureEventGridSimulator.Infrastructure.Settings; +using Shouldly; +using Xunit; + +namespace AzureEventGridSimulator.Tests.UnitTests.Management; + +[Trait("Category", "unit")] +public class ManagementPortValidationTests +{ + private static SimulatorSettings SettingsWith(int topicPort, int? managementPort) => + new() + { + DashboardEnabled = false, + ManagementPort = managementPort, + Topics = [new TopicSettings { Name = "Topic", Port = topicPort }], + }; + + [Fact] + public void Should_Throw_When_ManagementPortClashesWithTopicPort() + { + var settings = SettingsWith(topicPort: 60101, managementPort: 60101); + + Should + .Throw(() => settings.Validate()) + .Message.ShouldContain("management port"); + } + + [Fact] + public void Should_Pass_When_ManagementPortIsDistinct() + { + var settings = SettingsWith(topicPort: 60101, managementPort: 60100); + + Should.NotThrow(() => settings.Validate()); + } +} diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Management/SubscribersSettingsRegistryTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Management/SubscribersSettingsRegistryTests.cs new file mode 100644 index 0000000..f3328ab --- /dev/null +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Management/SubscribersSettingsRegistryTests.cs @@ -0,0 +1,93 @@ +using AzureEventGridSimulator.Infrastructure.Settings.Subscribers; +using Shouldly; +using Xunit; + +namespace AzureEventGridSimulator.Tests.UnitTests.Management; + +[Trait("Category", "unit")] +public class SubscribersSettingsRegistryTests +{ + private static HttpSubscriberSettings Subscriber(string name) => + new() { Name = name, Endpoint = $"https://{name}.test/hook" }; + + [Fact] + public void Should_AddSubscriber_When_Upserting() + { + var subscribers = new SubscribersSettings(); + + subscribers.UpsertHttpSubscriber(Subscriber("a")); + + subscribers.HttpSubscribers.Select(s => s.Name).ShouldBe(["a"]); + } + + [Fact] + public void Should_ReplaceByName_When_UpsertingExistingName() + { + var subscribers = new SubscribersSettings(); + subscribers.UpsertHttpSubscriber(Subscriber("a")); + + subscribers.UpsertHttpSubscriber( + new HttpSubscriberSettings { Name = "A", Endpoint = "https://updated.test/hook" } + ); + + var only = subscribers.HttpSubscribers.ShouldHaveSingleItem(); + only.Endpoint.ShouldBe("https://updated.test/hook"); + } + + [Fact] + public void Should_RemoveByNameCaseInsensitive_When_Removing() + { + var subscribers = new SubscribersSettings(); + subscribers.UpsertHttpSubscriber(Subscriber("a")); + + subscribers.RemoveHttpSubscriber("A").ShouldBeTrue(); + subscribers.HttpSubscribers.ShouldBeEmpty(); + } + + [Fact] + public void Should_ReturnFalse_When_RemovingUnknownSubscriber() + { + var subscribers = new SubscribersSettings(); + + subscribers.RemoveHttpSubscriber("missing").ShouldBeFalse(); + } + + [Fact] + public async Task Should_RemainConsistent_When_MutatedConcurrentlyWhileEnumerated() + { + var subscribers = new SubscribersSettings(); + + // Continuously enumerate the (lock-free) read path while other threads mutate it. Copy-on-write + // means enumeration must never throw, even under concurrent upserts and removes. + using var stop = new CancellationTokenSource(); + var reader = Task.Run(() => + { + while (!stop.IsCancellationRequested) + { + _ = subscribers.All.ToList(); + _ = subscribers.HttpSubscribers.Count(); + } + }); + + var writers = Enumerable + .Range(0, 8) + .Select(i => + Task.Run(() => + { + for (var j = 0; j < 200; j++) + { + var name = $"sub-{i}-{j % 10}"; + subscribers.UpsertHttpSubscriber(Subscriber(name)); + subscribers.RemoveHttpSubscriber(name); + } + }) + ) + .ToArray(); + + await Task.WhenAll(writers); + await stop.CancelAsync(); + await reader; + + subscribers.HttpSubscribers.ShouldBeEmpty(); + } +} diff --git a/src/AzureEventGridSimulator.Tests/appsettings.test.json b/src/AzureEventGridSimulator.Tests/appsettings.test.json index 032bef3..1639470 100644 --- a/src/AzureEventGridSimulator.Tests/appsettings.test.json +++ b/src/AzureEventGridSimulator.Tests/appsettings.test.json @@ -1,4 +1,5 @@ { + "managementPort": 60100, "topics": [ { "name": "ATopicWithATestSubscriber", @@ -47,6 +48,16 @@ } ] } + }, + { + "name": "ManagementTopic", + "port": 60103, + "key": "TheLocal+DevelopmentKey=" + }, + { + "name": "RuntimeDeliveryTopic", + "port": 60104, + "key": "TheLocal+DevelopmentKey=" } ], "Serilog": { diff --git a/src/AzureEventGridSimulator/Controllers/EventSubscriptionsManagementController.cs b/src/AzureEventGridSimulator/Controllers/EventSubscriptionsManagementController.cs new file mode 100644 index 0000000..e8575e8 --- /dev/null +++ b/src/AzureEventGridSimulator/Controllers/EventSubscriptionsManagementController.cs @@ -0,0 +1,94 @@ +using Asp.Versioning; +using AzureEventGridSimulator.Domain; +using AzureEventGridSimulator.Domain.Commands; +using AzureEventGridSimulator.Domain.Entities.Management; +using AzureEventGridSimulator.Domain.Services.Management; +using AzureEventGridSimulator.Infrastructure.Mediator; +using Microsoft.AspNetCore.Mvc; + +namespace AzureEventGridSimulator.Controllers; + +/// +/// The ARM control-plane facade. It speaks the same HTTP that the Azure.ResourceManager.EventGrid +/// client emits for topic-scoped event subscriptions, so that client can create, get, list and +/// delete subscriptions against the simulator at runtime simply by being repointed at the +/// management port. WebHook and StorageQueue destinations are supported. +/// +[ApiController] +[ApiVersion(Constants.SupportedManagementApiVersion)] +[Route( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}" + + "/providers/Microsoft.EventGrid/topics/{topicName}/eventSubscriptions" +)] +public class EventSubscriptionsManagementController(IMediator mediator) : ControllerBase +{ + [HttpPut("{eventSubscriptionName}")] + public async Task CreateOrUpdate( + string subscriptionId, + string resourceGroupName, + string topicName, + string eventSubscriptionName, + [FromBody] ArmEventSubscriptionResource resource + ) + { + var scope = new EventSubscriptionScope(subscriptionId, resourceGroupName, topicName); + + var result = await mediator.Send( + new CreateOrUpdateEventSubscriptionCommand(scope, eventSubscriptionName, resource) + ); + + return result.Outcome switch + { + EventSubscriptionWriteOutcome.Created => Created( + EventSubscriptionMapper.BuildResourceId(scope, eventSubscriptionName), + result.Resource + ), + EventSubscriptionWriteOutcome.Updated => Ok(result.Resource), + EventSubscriptionWriteOutcome.TopicNotFound => NotFound(), + _ => BadRequest(), + }; + } + + [HttpGet("{eventSubscriptionName}")] + public async Task Get( + string subscriptionId, + string resourceGroupName, + string topicName, + string eventSubscriptionName + ) + { + var resource = await mediator.Send( + new GetEventSubscriptionCommand( + new EventSubscriptionScope(subscriptionId, resourceGroupName, topicName), + eventSubscriptionName + ) + ); + + return resource is null ? NotFound() : Ok(resource); + } + + [HttpDelete("{eventSubscriptionName}")] + public async Task Delete(string topicName, string eventSubscriptionName) + { + await mediator.Send(new DeleteEventSubscriptionCommand(topicName, eventSubscriptionName)); + + // Delete is idempotent: Azure returns success whether or not the subscription existed. + return Ok(); + } + + [HttpGet] + public async Task List( + string subscriptionId, + string resourceGroupName, + string topicName + ) + { + var list = await mediator.Send( + new ListEventSubscriptionsCommand( + new EventSubscriptionScope(subscriptionId, resourceGroupName, topicName) + ) + ); + + return list is null ? NotFound() : Ok(list); + } +} diff --git a/src/AzureEventGridSimulator/Domain/Commands/CreateOrUpdateEventSubscriptionCommand.cs b/src/AzureEventGridSimulator/Domain/Commands/CreateOrUpdateEventSubscriptionCommand.cs new file mode 100644 index 0000000..bfc8c00 --- /dev/null +++ b/src/AzureEventGridSimulator/Domain/Commands/CreateOrUpdateEventSubscriptionCommand.cs @@ -0,0 +1,31 @@ +using AzureEventGridSimulator.Domain.Entities.Management; +using AzureEventGridSimulator.Domain.Services.Management; +using AzureEventGridSimulator.Infrastructure.Mediator; + +namespace AzureEventGridSimulator.Domain.Commands; + +public enum EventSubscriptionWriteOutcome +{ + Created, + Updated, + TopicNotFound, + UnsupportedDestination, +} + +public record CreateOrUpdateEventSubscriptionResult( + EventSubscriptionWriteOutcome Outcome, + ArmEventSubscriptionResource? Resource +); + +public class CreateOrUpdateEventSubscriptionCommand( + EventSubscriptionScope scope, + string eventSubscriptionName, + ArmEventSubscriptionResource resource +) : IRequest +{ + public EventSubscriptionScope Scope { get; } = scope; + + public string EventSubscriptionName { get; } = eventSubscriptionName; + + public ArmEventSubscriptionResource Resource { get; } = resource; +} diff --git a/src/AzureEventGridSimulator/Domain/Commands/CreateOrUpdateEventSubscriptionCommandHandler.cs b/src/AzureEventGridSimulator/Domain/Commands/CreateOrUpdateEventSubscriptionCommandHandler.cs new file mode 100644 index 0000000..b45f00e --- /dev/null +++ b/src/AzureEventGridSimulator/Domain/Commands/CreateOrUpdateEventSubscriptionCommandHandler.cs @@ -0,0 +1,118 @@ +using AzureEventGridSimulator.Domain.Services.Management; +using AzureEventGridSimulator.Domain.Services.Validation; +using AzureEventGridSimulator.Infrastructure.Mediator; +using AzureEventGridSimulator.Infrastructure.Settings; +using AzureEventGridSimulator.Infrastructure.Settings.Subscribers; +using JetBrains.Annotations; + +namespace AzureEventGridSimulator.Domain.Commands; + +// ReSharper disable once UnusedMember.Global +[UsedImplicitly] +public class CreateOrUpdateEventSubscriptionCommandHandler( + SimulatorSettings simulatorSettings, + SubscriptionValidationSender validationSender, + ILogger logger +) : IRequestHandler +{ + public async Task Handle( + CreateOrUpdateEventSubscriptionCommand request, + CancellationToken cancellationToken + ) + { + var topic = simulatorSettings.Topics.FirstOrDefault(t => + string.Equals(t.Name, request.Scope.TopicName, StringComparison.OrdinalIgnoreCase) + ); + + if (topic is null) + { + return new CreateOrUpdateEventSubscriptionResult( + EventSubscriptionWriteOutcome.TopicNotFound, + null + ); + } + + if ( + EventSubscriptionMapper.TryMapToHttpSubscriber( + request.EventSubscriptionName, + request.Resource, + out var httpSubscriber + ) + ) + { + var alreadyExisted = topic.Subscribers.HttpSubscribers.Any(s => + string.Equals( + s.Name, + request.EventSubscriptionName, + StringComparison.OrdinalIgnoreCase + ) + ); + + topic.Subscribers.UpsertHttpSubscriber(httpSubscriber!); + + logger.LogInformation( + "{Action} runtime event subscription '{SubscriptionName}' (webhook) on topic '{TopicName}'", + alreadyExisted ? "Updated" : "Created", + httpSubscriber!.Name, + topic.Name + ); + + // Azure performs the webhook validation handshake while provisioning; the simulator gates + // delivery on it. Run it now so the subscription starts receiving events immediately. + if (!httpSubscriber.DisableValidation) + { + await validationSender.ValidateAsync(topic, httpSubscriber, cancellationToken); + } + + return new CreateOrUpdateEventSubscriptionResult( + alreadyExisted + ? EventSubscriptionWriteOutcome.Updated + : EventSubscriptionWriteOutcome.Created, + EventSubscriptionMapper.MapToArm(request.Scope, httpSubscriber) + ); + } + + if ( + EventSubscriptionMapper.TryMapToStorageQueueSubscriber( + request.EventSubscriptionName, + request.Resource, + out var queueSubscriber + ) + ) + { + // Inherit the topic-level storageQueueConnectionString for delivery. + queueSubscriber!.ParentTopic = topic; + + var alreadyExisted = topic.Subscribers.StorageQueueSubscribers.Any(s => + string.Equals( + s.Name, + request.EventSubscriptionName, + StringComparison.OrdinalIgnoreCase + ) + ); + + topic.Subscribers.UpsertStorageQueueSubscriber(queueSubscriber); + + logger.LogInformation( + "{Action} runtime event subscription '{SubscriptionName}' (storage queue '{QueueName}') on topic '{TopicName}'", + alreadyExisted ? "Updated" : "Created", + queueSubscriber.Name, + queueSubscriber.QueueName, + topic.Name + ); + + // Storage-queue destinations have no validation handshake (that is webhook-only). + return new CreateOrUpdateEventSubscriptionResult( + alreadyExisted + ? EventSubscriptionWriteOutcome.Updated + : EventSubscriptionWriteOutcome.Created, + EventSubscriptionMapper.MapToArm(request.Scope, queueSubscriber) + ); + } + + return new CreateOrUpdateEventSubscriptionResult( + EventSubscriptionWriteOutcome.UnsupportedDestination, + null + ); + } +} diff --git a/src/AzureEventGridSimulator/Domain/Commands/DeleteEventSubscriptionCommand.cs b/src/AzureEventGridSimulator/Domain/Commands/DeleteEventSubscriptionCommand.cs new file mode 100644 index 0000000..404bbf5 --- /dev/null +++ b/src/AzureEventGridSimulator/Domain/Commands/DeleteEventSubscriptionCommand.cs @@ -0,0 +1,11 @@ +using AzureEventGridSimulator.Infrastructure.Mediator; + +namespace AzureEventGridSimulator.Domain.Commands; + +public class DeleteEventSubscriptionCommand(string topicName, string eventSubscriptionName) + : IRequest +{ + public string TopicName { get; } = topicName; + + public string EventSubscriptionName { get; } = eventSubscriptionName; +} diff --git a/src/AzureEventGridSimulator/Domain/Commands/DeleteEventSubscriptionCommandHandler.cs b/src/AzureEventGridSimulator/Domain/Commands/DeleteEventSubscriptionCommandHandler.cs new file mode 100644 index 0000000..d5f0819 --- /dev/null +++ b/src/AzureEventGridSimulator/Domain/Commands/DeleteEventSubscriptionCommandHandler.cs @@ -0,0 +1,41 @@ +using AzureEventGridSimulator.Infrastructure.Mediator; +using AzureEventGridSimulator.Infrastructure.Settings; +using JetBrains.Annotations; + +namespace AzureEventGridSimulator.Domain.Commands; + +// ReSharper disable once UnusedMember.Global +[UsedImplicitly] +public class DeleteEventSubscriptionCommandHandler( + SimulatorSettings simulatorSettings, + ILogger logger +) : IRequestHandler +{ + public Task Handle( + DeleteEventSubscriptionCommand request, + CancellationToken cancellationToken + ) + { + var topic = simulatorSettings.Topics.FirstOrDefault(t => + string.Equals(t.Name, request.TopicName, StringComparison.OrdinalIgnoreCase) + ); + + var removed = + (topic?.Subscribers.RemoveHttpSubscriber(request.EventSubscriptionName) ?? false) + || ( + topic?.Subscribers.RemoveStorageQueueSubscriber(request.EventSubscriptionName) + ?? false + ); + + if (removed) + { + logger.LogInformation( + "Deleted runtime event subscription '{SubscriptionName}' on topic '{TopicName}'", + request.EventSubscriptionName, + request.TopicName + ); + } + + return Task.FromResult(removed); + } +} diff --git a/src/AzureEventGridSimulator/Domain/Commands/GetEventSubscriptionCommand.cs b/src/AzureEventGridSimulator/Domain/Commands/GetEventSubscriptionCommand.cs new file mode 100644 index 0000000..bc808cf --- /dev/null +++ b/src/AzureEventGridSimulator/Domain/Commands/GetEventSubscriptionCommand.cs @@ -0,0 +1,13 @@ +using AzureEventGridSimulator.Domain.Entities.Management; +using AzureEventGridSimulator.Domain.Services.Management; +using AzureEventGridSimulator.Infrastructure.Mediator; + +namespace AzureEventGridSimulator.Domain.Commands; + +public class GetEventSubscriptionCommand(EventSubscriptionScope scope, string eventSubscriptionName) + : IRequest +{ + public EventSubscriptionScope Scope { get; } = scope; + + public string EventSubscriptionName { get; } = eventSubscriptionName; +} diff --git a/src/AzureEventGridSimulator/Domain/Commands/GetEventSubscriptionCommandHandler.cs b/src/AzureEventGridSimulator/Domain/Commands/GetEventSubscriptionCommandHandler.cs new file mode 100644 index 0000000..23a1504 --- /dev/null +++ b/src/AzureEventGridSimulator/Domain/Commands/GetEventSubscriptionCommandHandler.cs @@ -0,0 +1,36 @@ +using AzureEventGridSimulator.Domain.Entities.Management; +using AzureEventGridSimulator.Domain.Services.Management; +using AzureEventGridSimulator.Infrastructure.Mediator; +using AzureEventGridSimulator.Infrastructure.Settings; +using AzureEventGridSimulator.Infrastructure.Settings.Subscribers; +using JetBrains.Annotations; + +namespace AzureEventGridSimulator.Domain.Commands; + +// ReSharper disable once UnusedMember.Global +[UsedImplicitly] +public class GetEventSubscriptionCommandHandler(SimulatorSettings simulatorSettings) + : IRequestHandler +{ + public Task Handle( + GetEventSubscriptionCommand request, + CancellationToken cancellationToken + ) + { + var topic = simulatorSettings.Topics.FirstOrDefault(t => + string.Equals(t.Name, request.Scope.TopicName, StringComparison.OrdinalIgnoreCase) + ); + + ISubscriberSettings? subscriber = topic?.Subscribers.HttpSubscribers.FirstOrDefault(s => + string.Equals(s.Name, request.EventSubscriptionName, StringComparison.OrdinalIgnoreCase) + ); + + subscriber ??= topic?.Subscribers.StorageQueueSubscribers.FirstOrDefault(s => + string.Equals(s.Name, request.EventSubscriptionName, StringComparison.OrdinalIgnoreCase) + ); + + return Task.FromResult( + subscriber is null ? null : EventSubscriptionMapper.MapToArm(request.Scope, subscriber) + ); + } +} diff --git a/src/AzureEventGridSimulator/Domain/Commands/ListEventSubscriptionsCommand.cs b/src/AzureEventGridSimulator/Domain/Commands/ListEventSubscriptionsCommand.cs new file mode 100644 index 0000000..db5211d --- /dev/null +++ b/src/AzureEventGridSimulator/Domain/Commands/ListEventSubscriptionsCommand.cs @@ -0,0 +1,11 @@ +using AzureEventGridSimulator.Domain.Entities.Management; +using AzureEventGridSimulator.Domain.Services.Management; +using AzureEventGridSimulator.Infrastructure.Mediator; + +namespace AzureEventGridSimulator.Domain.Commands; + +public class ListEventSubscriptionsCommand(EventSubscriptionScope scope) + : IRequest +{ + public EventSubscriptionScope Scope { get; } = scope; +} diff --git a/src/AzureEventGridSimulator/Domain/Commands/ListEventSubscriptionsCommandHandler.cs b/src/AzureEventGridSimulator/Domain/Commands/ListEventSubscriptionsCommandHandler.cs new file mode 100644 index 0000000..58f8d71 --- /dev/null +++ b/src/AzureEventGridSimulator/Domain/Commands/ListEventSubscriptionsCommandHandler.cs @@ -0,0 +1,44 @@ +using AzureEventGridSimulator.Domain.Entities.Management; +using AzureEventGridSimulator.Domain.Services.Management; +using AzureEventGridSimulator.Infrastructure.Mediator; +using AzureEventGridSimulator.Infrastructure.Settings; +using JetBrains.Annotations; + +namespace AzureEventGridSimulator.Domain.Commands; + +// ReSharper disable once UnusedMember.Global +[UsedImplicitly] +public class ListEventSubscriptionsCommandHandler(SimulatorSettings simulatorSettings) + : IRequestHandler +{ + public Task Handle( + ListEventSubscriptionsCommand request, + CancellationToken cancellationToken + ) + { + var topic = simulatorSettings.Topics.FirstOrDefault(t => + string.Equals(t.Name, request.Scope.TopicName, StringComparison.OrdinalIgnoreCase) + ); + + if (topic is null) + { + return Task.FromResult(null); + } + + var list = new ArmEventSubscriptionList + { + Value = topic + .Subscribers.HttpSubscribers.Select(s => + EventSubscriptionMapper.MapToArm(request.Scope, s) + ) + .Concat( + topic.Subscribers.StorageQueueSubscribers.Select(s => + EventSubscriptionMapper.MapToArm(request.Scope, s) + ) + ) + .ToList(), + }; + + return Task.FromResult(list); + } +} diff --git a/src/AzureEventGridSimulator/Domain/Commands/ValidateAllSubscriptionsCommandHandler.cs b/src/AzureEventGridSimulator/Domain/Commands/ValidateAllSubscriptionsCommandHandler.cs index 9737877..f964542 100644 --- a/src/AzureEventGridSimulator/Domain/Commands/ValidateAllSubscriptionsCommandHandler.cs +++ b/src/AzureEventGridSimulator/Domain/Commands/ValidateAllSubscriptionsCommandHandler.cs @@ -1,11 +1,6 @@ -using System.Text; -using System.Text.Json; -using AzureEventGridSimulator.Domain.Entities; -using AzureEventGridSimulator.Domain.Services; -using AzureEventGridSimulator.Infrastructure; +using AzureEventGridSimulator.Domain.Services.Validation; using AzureEventGridSimulator.Infrastructure.Mediator; using AzureEventGridSimulator.Infrastructure.Settings; -using AzureEventGridSimulator.Infrastructure.Settings.Subscribers; using JetBrains.Annotations; namespace AzureEventGridSimulator.Domain.Commands; @@ -13,11 +8,8 @@ namespace AzureEventGridSimulator.Domain.Commands; // ReSharper disable once UnusedMember.Global [UsedImplicitly] public class ValidateAllSubscriptionsCommandHandler( - ILogger logger, - IHttpClientFactory httpClientFactory, SimulatorSettings simulatorSettings, - ValidationIpAddressProvider validationIpAddress, - TimeProvider timeProvider + SubscriptionValidationSender validationSender ) : IRequestHandler { public async Task Handle( @@ -34,108 +26,8 @@ var subscriber in enabledTopic.Subscribers.HttpSubscribers.Where(o => ) ) { - await ValidateSubscription(enabledTopic, subscriber, cancellationToken); + await validationSender.ValidateAsync(enabledTopic, subscriber, cancellationToken); } } } - - private async Task ValidateSubscription( - TopicSettings topic, - HttpSubscriberSettings subscription, - CancellationToken cancellationToken - ) - { - var validationUrl = - $"https://{validationIpAddress}:{topic.Port}/validate?id={subscription.ValidationCode}"; - - try - { - logger.LogDebug( - "Sending subscription validation event to subscriber '{SubscriberName}'", - subscription.Name - ); - - var evt = new EventGridEvent - { - EventTime = timeProvider.GetUtcNow().ToString("o"), - DataVersion = "1", - EventType = "Microsoft.EventGrid.SubscriptionValidationEvent", - Id = Guid.NewGuid().ToString(), - Subject = "", - MetadataVersion = "1", - Data = new SubscriptionValidationRequest - { - ValidationCode = subscription.ValidationCode, - ValidationUrl = - $"https://{validationIpAddress}:{topic.Port}/validate?id={subscription.ValidationCode}", - }, - }; - - var json = JsonSerializer.Serialize( - new[] { evt }, - new JsonSerializerOptions { WriteIndented = true } - ); - using var content = new StringContent(json, Encoding.UTF8, "application/json"); - // Use the named client so the optional DangerousAcceptAnyServerCertificateValidator applies - using var httpClient = httpClientFactory.CreateClient(nameof(AzureEventGridSimulator)); - httpClient.DefaultRequestHeaders.Add( - Constants.AegEventTypeHeader, - Constants.ValidationEventType - ); - httpClient.DefaultRequestHeaders.Add( - Constants.AegSubscriptionNameHeader, - subscription.Name.ToUpperInvariant() - ); - httpClient.DefaultRequestHeaders.Add(Constants.AegDataVersionHeader, evt.DataVersion); - httpClient.DefaultRequestHeaders.Add( - Constants.AegMetadataVersionHeader, - evt.MetadataVersion - ); - httpClient.DefaultRequestHeaders.Add(Constants.AegDeliveryCountHeader, "0"); // TODO implement re-tries - httpClient.Timeout = TimeSpan.FromSeconds(60); - - subscription.ValidationStatus = SubscriptionValidationStatus.ValidationEventSent; - - using var response = await httpClient.PostAsync( - subscription.Endpoint, - content, - cancellationToken - ); - response.EnsureSuccessStatusCode(); - - var text = await response.Content.ReadAsStringAsync(cancellationToken); - var validationResponse = JsonSerializer.Deserialize( - text - ); - - if ( - validationResponse != null - && validationResponse.ValidationResponse == subscription.ValidationCode - ) - { - subscription.ValidationStatus = SubscriptionValidationStatus.ValidationSuccessful; - logger.LogInformation( - "Successfully validated subscriber '{SubscriberName}'", - subscription.Name - ); - return; - } - } - catch (Exception ex) - { - logger.LogError( - ex, - "Failed to validate subscriber '{SubscriberName}'. Note that subscriber must be started before the simulator. Or you can disable validation for this subscriber via settings: '{Error}'", - subscription.Name, - ex.Message - ); - logger.LogInformation( - "'{SubscriberName}' manual validation url: {ValidationUrl}", - subscription.Name, - validationUrl - ); - } - - subscription.ValidationStatus = SubscriptionValidationStatus.ValidationFailed; - } } diff --git a/src/AzureEventGridSimulator/Domain/Constants.cs b/src/AzureEventGridSimulator/Domain/Constants.cs index f7873a8..cd22bcc 100644 --- a/src/AzureEventGridSimulator/Domain/Constants.cs +++ b/src/AzureEventGridSimulator/Domain/Constants.cs @@ -20,6 +20,10 @@ public static class Constants // Newer versions (2023-11-01, 2024-01-01, 2024-06-01) are for Namespace Topics only public const string SupportedApiVersion = "2018-01-01"; + // The ARM control-plane (management) API version emitted by Azure.ResourceManager.EventGrid. + // The management facade pins this version; update it if the management SDK is upgraded. + public const string SupportedManagementApiVersion = "2025-02-15"; + public const string SasAuthorizationType = "SharedAccessSignature"; // CloudEvents Headers (binary mode) diff --git a/src/AzureEventGridSimulator/Domain/Entities/Management/ArmEventSubscription.cs b/src/AzureEventGridSimulator/Domain/Entities/Management/ArmEventSubscription.cs new file mode 100644 index 0000000..4efcc63 --- /dev/null +++ b/src/AzureEventGridSimulator/Domain/Entities/Management/ArmEventSubscription.cs @@ -0,0 +1,111 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AzureEventGridSimulator.Domain.Entities.Management; + +/// +/// ARM resource representation of an Event Grid event subscription, matching the JSON shape the +/// Azure.ResourceManager.EventGrid client sends and expects on the control plane. +/// +public class ArmEventSubscriptionResource +{ + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("type")] + public string? Type { get; set; } + + [JsonPropertyName("properties")] + public ArmEventSubscriptionProperties? Properties { get; set; } +} + +public class ArmEventSubscriptionProperties +{ + [JsonPropertyName("provisioningState")] + public string? ProvisioningState { get; set; } + + [JsonPropertyName("destination")] + public ArmEventSubscriptionDestination? Destination { get; set; } + + [JsonPropertyName("filter")] + public ArmEventSubscriptionFilter? Filter { get; set; } +} + +public class ArmEventSubscriptionDestination +{ + [JsonPropertyName("endpointType")] + public string? EndpointType { get; set; } + + [JsonPropertyName("properties")] + public ArmEventSubscriptionDestinationProperties? Properties { get; set; } +} + +public class ArmEventSubscriptionDestinationProperties +{ + /// + /// The full webhook URL. Azure treats this as write-only and only returns + /// on reads; the simulator echoes it back so that the + /// read-modify-write pattern (e.g. updating a filter) preserves the destination. + /// + [JsonPropertyName("endpointUrl")] + public string? EndpointUrl { get; set; } + + [JsonPropertyName("endpointBaseUrl")] + public string? EndpointBaseUrl { get; set; } + + // Storage-queue destination properties (ARM nests destination-type-specific properties under the + // one "properties" object; only the fields for the active endpointType are populated). + [JsonPropertyName("resourceId")] + public string? ResourceId { get; set; } + + [JsonPropertyName("queueName")] + public string? QueueName { get; set; } +} + +public class ArmEventSubscriptionFilter +{ + [JsonPropertyName("includedEventTypes")] + public List? IncludedEventTypes { get; set; } + + [JsonPropertyName("subjectBeginsWith")] + public string? SubjectBeginsWith { get; set; } + + [JsonPropertyName("subjectEndsWith")] + public string? SubjectEndsWith { get; set; } + + [JsonPropertyName("isSubjectCaseSensitive")] + public bool? IsSubjectCaseSensitive { get; set; } + + [JsonPropertyName("advancedFilters")] + public List? AdvancedFilters { get; set; } +} + +public class ArmAdvancedFilter +{ + [JsonPropertyName("operatorType")] + public string? OperatorType { get; set; } + + [JsonPropertyName("key")] + public string? Key { get; set; } + + [JsonPropertyName("value")] + public JsonElement? Value { get; set; } + + [JsonPropertyName("values")] + public List? Values { get; set; } +} + +/// +/// ARM list response wrapper for a collection of event subscriptions. +/// +public class ArmEventSubscriptionList +{ + [JsonPropertyName("value")] + public List Value { get; set; } = []; + + [JsonPropertyName("nextLink")] + public string? NextLink { get; set; } +} diff --git a/src/AzureEventGridSimulator/Domain/Services/Management/EventSubscriptionMapper.cs b/src/AzureEventGridSimulator/Domain/Services/Management/EventSubscriptionMapper.cs new file mode 100644 index 0000000..ed67672 --- /dev/null +++ b/src/AzureEventGridSimulator/Domain/Services/Management/EventSubscriptionMapper.cs @@ -0,0 +1,240 @@ +using System.Text.Json; +using AzureEventGridSimulator.Domain.Entities.Management; +using AzureEventGridSimulator.Infrastructure.Settings; +using AzureEventGridSimulator.Infrastructure.Settings.Subscribers; + +namespace AzureEventGridSimulator.Domain.Services.Management; + +/// +/// The ARM scope of an event subscription, as taken from the request URL. +/// +public readonly record struct EventSubscriptionScope( + string SubscriptionId, + string ResourceGroupName, + string TopicName +); + +/// +/// Translates between the ARM wire shape used by the +/// Azure.ResourceManager.EventGrid client and the simulator's internal +/// . WebHook and StorageQueue destinations are supported. +/// +public static class EventSubscriptionMapper +{ + public const string WebHookEndpointType = "WebHook"; + + public const string StorageQueueEndpointType = "StorageQueue"; + + private const string EventSubscriptionType = "Microsoft.EventGrid/topics/eventSubscriptions"; + + /// + /// Maps an ARM event subscription resource to an HTTP subscriber. Returns false when the + /// destination is missing or is not a WebHook (the only destination type the management API + /// supports today). + /// + public static bool TryMapToHttpSubscriber( + string name, + ArmEventSubscriptionResource resource, + out HttpSubscriberSettings? subscriber + ) + { + subscriber = null; + + var destination = resource.Properties?.Destination; + if ( + destination is null + || !string.Equals( + destination.EndpointType, + WebHookEndpointType, + StringComparison.OrdinalIgnoreCase + ) + || string.IsNullOrWhiteSpace(destination.Properties?.EndpointUrl) + ) + { + return false; + } + + subscriber = new HttpSubscriberSettings + { + Name = name, + Endpoint = destination.Properties.EndpointUrl, + Filter = MapFilter(resource.Properties?.Filter), + }; + + return true; + } + + /// + /// Maps an ARM event subscription resource to a Storage Queue subscriber. Returns false when the + /// destination is missing, is not a StorageQueue, or has no queueName. The connection string is + /// left null so it inherits the topic-level storageQueueConnectionString. + /// + public static bool TryMapToStorageQueueSubscriber( + string name, + ArmEventSubscriptionResource resource, + out StorageQueueSubscriberSettings? subscriber + ) + { + subscriber = null; + + var destination = resource.Properties?.Destination; + if ( + destination is null + || !string.Equals( + destination.EndpointType, + StorageQueueEndpointType, + StringComparison.OrdinalIgnoreCase + ) + || string.IsNullOrWhiteSpace(destination.Properties?.QueueName) + ) + { + return false; + } + + subscriber = new StorageQueueSubscriberSettings + { + Name = name, + QueueName = destination.Properties.QueueName!, + SourceResourceId = destination.Properties.ResourceId, + Filter = MapFilter(resource.Properties?.Filter), + }; + + return true; + } + + public static ArmEventSubscriptionResource MapToArm( + EventSubscriptionScope scope, + ISubscriberSettings subscriber + ) => + subscriber switch + { + HttpSubscriberSettings http => MapToArm(scope, http), + StorageQueueSubscriberSettings queue => MapToArm(scope, queue), + _ => throw new NotSupportedException( + $"Cannot map subscriber type '{subscriber.SubscriberType}' to an ARM event subscription." + ), + }; + + public static ArmEventSubscriptionResource MapToArm( + EventSubscriptionScope scope, + HttpSubscriberSettings subscriber + ) => + new() + { + Id = BuildResourceId(scope, subscriber.Name), + Name = subscriber.Name, + Type = EventSubscriptionType, + Properties = new ArmEventSubscriptionProperties + { + ProvisioningState = "Succeeded", + Destination = new ArmEventSubscriptionDestination + { + EndpointType = WebHookEndpointType, + Properties = new ArmEventSubscriptionDestinationProperties + { + EndpointUrl = subscriber.Endpoint, + EndpointBaseUrl = subscriber.Endpoint, + }, + }, + Filter = MapFilter(subscriber.Filter), + }, + }; + + public static ArmEventSubscriptionResource MapToArm( + EventSubscriptionScope scope, + StorageQueueSubscriberSettings subscriber + ) => + new() + { + Id = BuildResourceId(scope, subscriber.Name), + Name = subscriber.Name, + Type = EventSubscriptionType, + Properties = new ArmEventSubscriptionProperties + { + ProvisioningState = "Succeeded", + Destination = new ArmEventSubscriptionDestination + { + EndpointType = StorageQueueEndpointType, + Properties = new ArmEventSubscriptionDestinationProperties + { + ResourceId = subscriber.SourceResourceId, + QueueName = subscriber.QueueName, + }, + }, + Filter = MapFilter(subscriber.Filter), + }, + }; + + public static string BuildResourceId(EventSubscriptionScope scope, string name) => + $"/subscriptions/{scope.SubscriptionId}/resourceGroups/{scope.ResourceGroupName}" + + $"/providers/Microsoft.EventGrid/topics/{scope.TopicName}/eventSubscriptions/{name}"; + + private static FilterSetting? MapFilter(ArmEventSubscriptionFilter? filter) + { + if (filter is null) + { + return null; + } + + return new FilterSetting + { + IncludedEventTypes = filter.IncludedEventTypes is { Count: > 0 } + ? filter.IncludedEventTypes.ToList() + : null, + SubjectBeginsWith = filter.SubjectBeginsWith, + SubjectEndsWith = filter.SubjectEndsWith, + IsSubjectCaseSensitive = filter.IsSubjectCaseSensitive ?? false, + AdvancedFilters = filter + .AdvancedFilters?.Select(MapAdvancedFilter) + .ToList(), + }; + } + + private static ArmEventSubscriptionFilter? MapFilter(FilterSetting? filter) + { + if (filter is null) + { + return null; + } + + return new ArmEventSubscriptionFilter + { + IncludedEventTypes = filter.IncludedEventTypes?.ToList(), + SubjectBeginsWith = filter.SubjectBeginsWith, + SubjectEndsWith = filter.SubjectEndsWith, + IsSubjectCaseSensitive = filter.IsSubjectCaseSensitive, + AdvancedFilters = filter.AdvancedFilters?.Select(MapAdvancedFilter).ToList(), + }; + } + + private static AdvancedFilterSetting MapAdvancedFilter(ArmAdvancedFilter filter) => + new() + { + OperatorType = Enum.Parse( + filter.OperatorType ?? string.Empty, + ignoreCase: true + ), + Key = filter.Key, + Value = filter.Value.HasValue ? ToClrValue(filter.Value.Value) : null, + Values = filter.Values?.Select(ToClrValue).ToList(), + }; + + private static ArmAdvancedFilter MapAdvancedFilter(AdvancedFilterSetting filter) => + new() + { + OperatorType = filter.OperatorType.ToString(), + Key = filter.Key, + Value = filter.Value is null ? null : JsonSerializer.SerializeToElement(filter.Value), + Values = filter.Values?.Select(v => JsonSerializer.SerializeToElement(v)).ToList(), + }; + + private static object ToClrValue(JsonElement element) => + element.ValueKind switch + { + JsonValueKind.String => element.GetString() ?? string.Empty, + JsonValueKind.Number => element.TryGetInt64(out var l) ? l : element.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + _ => element.GetRawText(), + }; +} diff --git a/src/AzureEventGridSimulator/Domain/Services/Validation/SubscriptionValidationSender.cs b/src/AzureEventGridSimulator/Domain/Services/Validation/SubscriptionValidationSender.cs new file mode 100644 index 0000000..a72ffe2 --- /dev/null +++ b/src/AzureEventGridSimulator/Domain/Services/Validation/SubscriptionValidationSender.cs @@ -0,0 +1,122 @@ +using System.Text; +using System.Text.Json; +using AzureEventGridSimulator.Domain.Entities; +using AzureEventGridSimulator.Infrastructure; +using AzureEventGridSimulator.Infrastructure.Settings; +using AzureEventGridSimulator.Infrastructure.Settings.Subscribers; + +namespace AzureEventGridSimulator.Domain.Services.Validation; + +/// +/// Performs the outbound Event Grid subscription-validation handshake against a single HTTP +/// subscriber: it sends a SubscriptionValidationEvent to the subscriber's endpoint and +/// marks the subscriber validated when the endpoint echoes the validation code. This mirrors the +/// handshake Azure performs when a webhook subscription is created, and is used both at boot and +/// when a subscription is created at runtime via the management API. +/// +public class SubscriptionValidationSender( + ILogger logger, + IHttpClientFactory httpClientFactory, + ValidationIpAddressProvider validationIpAddress, + TimeProvider timeProvider +) +{ + public async Task ValidateAsync( + TopicSettings topic, + HttpSubscriberSettings subscription, + CancellationToken cancellationToken + ) + { + var validationUrl = + $"https://{validationIpAddress}:{topic.Port}/validate?id={subscription.ValidationCode}"; + + try + { + logger.LogDebug( + "Sending subscription validation event to subscriber '{SubscriberName}'", + subscription.Name + ); + + var evt = new EventGridEvent + { + EventTime = timeProvider.GetUtcNow().ToString("o"), + DataVersion = "1", + EventType = "Microsoft.EventGrid.SubscriptionValidationEvent", + Id = Guid.NewGuid().ToString(), + Subject = "", + MetadataVersion = "1", + Data = new SubscriptionValidationRequest + { + ValidationCode = subscription.ValidationCode, + ValidationUrl = validationUrl, + }, + }; + + var json = JsonSerializer.Serialize( + new[] { evt }, + new JsonSerializerOptions { WriteIndented = true } + ); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + // Use the named client so the optional DangerousAcceptAnyServerCertificateValidator applies + using var httpClient = httpClientFactory.CreateClient(nameof(AzureEventGridSimulator)); + httpClient.DefaultRequestHeaders.Add( + Constants.AegEventTypeHeader, + Constants.ValidationEventType + ); + httpClient.DefaultRequestHeaders.Add( + Constants.AegSubscriptionNameHeader, + subscription.Name.ToUpperInvariant() + ); + httpClient.DefaultRequestHeaders.Add(Constants.AegDataVersionHeader, evt.DataVersion); + httpClient.DefaultRequestHeaders.Add( + Constants.AegMetadataVersionHeader, + evt.MetadataVersion + ); + httpClient.DefaultRequestHeaders.Add(Constants.AegDeliveryCountHeader, "0"); + httpClient.Timeout = TimeSpan.FromSeconds(60); + + subscription.ValidationStatus = SubscriptionValidationStatus.ValidationEventSent; + + using var response = await httpClient.PostAsync( + subscription.Endpoint, + content, + cancellationToken + ); + response.EnsureSuccessStatusCode(); + + var text = await response.Content.ReadAsStringAsync(cancellationToken); + var validationResponse = JsonSerializer.Deserialize( + text + ); + + if ( + validationResponse != null + && validationResponse.ValidationResponse == subscription.ValidationCode + ) + { + subscription.ValidationStatus = SubscriptionValidationStatus.ValidationSuccessful; + logger.LogInformation( + "Successfully validated subscriber '{SubscriberName}'", + subscription.Name + ); + return; + } + } + catch (Exception ex) + { + logger.LogError( + ex, + "Failed to validate subscriber '{SubscriberName}'. Note that subscriber must be started before the simulator. Or you can disable validation for this subscriber via settings: '{Error}'", + subscription.Name, + ex.Message + ); + logger.LogInformation( + "'{SubscriberName}' manual validation url: {ValidationUrl}", + subscription.Name, + validationUrl + ); + } + + subscription.ValidationStatus = SubscriptionValidationStatus.ValidationFailed; + } +} diff --git a/src/AzureEventGridSimulator/Infrastructure/Extensions/ValidationServiceExtensions.cs b/src/AzureEventGridSimulator/Infrastructure/Extensions/ValidationServiceExtensions.cs index d860325..e7e697c 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Extensions/ValidationServiceExtensions.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Extensions/ValidationServiceExtensions.cs @@ -20,6 +20,7 @@ public static IServiceCollection AddEventGridValidation(this IServiceCollection services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); return services; } diff --git a/src/AzureEventGridSimulator/Infrastructure/Middleware/EventGridMiddleware.cs b/src/AzureEventGridSimulator/Infrastructure/Middleware/EventGridMiddleware.cs index 9e54554..e992d11 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Middleware/EventGridMiddleware.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Middleware/EventGridMiddleware.cs @@ -23,6 +23,17 @@ public async Task InvokeAsync( ILogger logger ) { + // The ARM management API runs on its own port and is plain attribute-routed MVC; the + // data-plane request pipeline must never process it. + if ( + simulatorSettings.ManagementPort is { } managementPort + && context.Connection.LocalPort == managementPort + ) + { + await next(context); + return; + } + // Route the request to determine its type var route = requestRouter.RouteRequest(context); diff --git a/src/AzureEventGridSimulator/Infrastructure/Settings/SimulatorSettings.cs b/src/AzureEventGridSimulator/Infrastructure/Settings/SimulatorSettings.cs index 8f0e73e..be30250 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Settings/SimulatorSettings.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Settings/SimulatorSettings.cs @@ -25,6 +25,14 @@ public class SimulatorSettings [JsonPropertyName("eventValidationLimits")] public EventValidationLimits EventValidationLimits { get; set; } = new(); + /// + /// Optional port for the ARM management API (the control plane that creates and removes + /// event subscriptions at runtime). If not set, the management API is disabled and + /// subscriptions can only be configured at boot. + /// + [JsonPropertyName("managementPort")] + public int? ManagementPort { get; set; } + public void Validate() { if (Topics.GroupBy(o => o.Port).Count() != Topics.Length) @@ -32,6 +40,13 @@ public void Validate() throw new InvalidOperationException("Each topic must use a unique port."); } + if (ManagementPort is { } managementPort && Topics.Any(o => o.Port == managementPort)) + { + throw new InvalidOperationException( + "The management port must not be the same as a topic port." + ); + } + if (Topics.GroupBy(o => o.Name).Count() != Topics.Length) { throw new InvalidOperationException("Each topic must have a unique name."); diff --git a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/StorageQueueSubscriberSettings.cs b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/StorageQueueSubscriberSettings.cs index 4eec0ca..c0ccab9 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/StorageQueueSubscriberSettings.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/StorageQueueSubscriberSettings.cs @@ -27,6 +27,14 @@ public class StorageQueueSubscriberSettings : ISubscriberSettings [JsonPropertyName("queueName")] public required string QueueName { get; init; } + /// + /// The storage-account ARM resource id from a runtime (ARM-created) subscription's destination, + /// echoed back on reads so the control-plane round-trip is faithful. Null for statically-configured + /// subscribers; not part of the static config wire shape. + /// + [JsonIgnore] + public string? SourceResourceId { get; init; } + /// /// Gets the effective connection string, either from subscriber or topic level. /// diff --git a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/SubscribersSettings.cs b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/SubscribersSettings.cs index e95db5a..59669b2 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/SubscribersSettings.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/SubscribersSettings.cs @@ -79,6 +79,90 @@ public class SubscribersSettings [JsonIgnore] public int Count => All.Count(); + private readonly object _httpMutationLock = new(); + + private readonly object _storageQueueMutationLock = new(); + + /// + /// Adds or replaces (by name, case-insensitive) an HTTP subscriber at runtime. Mutations use + /// copy-on-write under a lock so that the delivery path, which enumerates the subscriber + /// collection without locking, always sees a consistent snapshot. + /// + public void UpsertHttpSubscriber(HttpSubscriberSettings subscriber) + { + lock (_httpMutationLock) + { + var retained = (Http ?? []) + .Where(s => + !string.Equals(s.Name, subscriber.Name, StringComparison.OrdinalIgnoreCase) + ) + .ToList(); + retained.Add(subscriber); + Http = [.. retained]; + } + } + + /// + /// Removes an HTTP subscriber by name (case-insensitive). Returns true if a subscriber was + /// removed. + /// + public bool RemoveHttpSubscriber(string name) + { + lock (_httpMutationLock) + { + var current = Http ?? []; + var retained = current + .Where(s => !string.Equals(s.Name, name, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + if (retained.Length == current.Length) + { + return false; + } + + Http = retained; + return true; + } + } + + /// + /// Adds or replaces (by name, case-insensitive) a Storage Queue subscriber at runtime. Same + /// copy-on-write-under-lock contract as . + /// + public void UpsertStorageQueueSubscriber(StorageQueueSubscriberSettings subscriber) + { + lock (_storageQueueMutationLock) + { + var retained = (StorageQueue ?? []) + .Where(s => + !string.Equals(s.Name, subscriber.Name, StringComparison.OrdinalIgnoreCase) + ) + .ToList(); + retained.Add(subscriber); + StorageQueue = [.. retained]; + } + } + + /// + /// Removes a Storage Queue subscriber by name (case-insensitive). Returns true if one was removed. + /// + public bool RemoveStorageQueueSubscriber(string name) + { + lock (_storageQueueMutationLock) + { + var current = StorageQueue ?? []; + var retained = current + .Where(s => !string.Equals(s.Name, name, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + if (retained.Length == current.Length) + { + return false; + } + StorageQueue = retained; + return true; + } + } + public void Validate() { foreach (var subscriber in All) diff --git a/src/AzureEventGridSimulator/Program.cs b/src/AzureEventGridSimulator/Program.cs index fea1f7d..a8447cf 100644 --- a/src/AzureEventGridSimulator/Program.cs +++ b/src/AzureEventGridSimulator/Program.cs @@ -392,6 +392,21 @@ IConfiguration configuration listenOptions => listenOptions.UseHttps() ); } + + // The ARM management API (control plane) listens on its own port, separate from the + // per-topic data-plane ports, mirroring how Azure splits management.azure.com from the + // topic endpoint. + var managementPort = options + .ApplicationServices.GetRequiredService() + .ManagementPort; + if (managementPort.HasValue) + { + options.Listen( + IPAddress.Any, + managementPort.Value, + listenOptions => listenOptions.UseHttps() + ); + } }); return builder; diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 15cbad8..654dc96 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -21,6 +21,7 @@ +