Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<port>/dashboard` to view event history and delivery status.
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
[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<WebHookEventSubscriptionDestination>();
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<string>();
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();
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
[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<bool> 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}.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

<ItemGroup>
<PackageReference Include="Azure.Messaging.EventGrid" />
<PackageReference Include="Azure.ResourceManager.EventGrid" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="Shouldly" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Azure.Core;

namespace AzureEventGridSimulator.Tests.Helpers;

/// <summary>
/// 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.
/// </summary>
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<AccessToken> GetTokenAsync(
TokenRequestContext requestContext,
CancellationToken ct
) => new(Token);
}
Loading