diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index fef4d632..1e31f56a 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -32,6 +32,7 @@ jobs: Hosting.Flagd.Tests, Hosting.GoFeatureFlag.Tests, Hosting.Golang.Tests, + Hosting.JavaScript.Extensions.Tests, Hosting.Java.Tests, Hosting.k6.Tests, Hosting.Keycloak.Extensions.Tests, @@ -44,7 +45,6 @@ jobs: Hosting.MongoDB.Extensions.Tests, Hosting.MySql.Extensions.Tests, Hosting.Ngrok.Tests, - Hosting.JavaScript.Extensions.Tests, Hosting.Ollama.Tests, Hosting.OpenTelemetryCollector.Tests, Hosting.PapercutSmtp.Tests, @@ -58,6 +58,7 @@ jobs: Hosting.SqlDatabaseProjects.Tests, Hosting.Sqlite.Tests, Hosting.SqlServer.Extensions.Tests, + Hosting.Stripe.Tests, Hosting.SurrealDb.Tests, # Client integration tests diff --git a/CommunityToolkit.Aspire.slnx b/CommunityToolkit.Aspire.slnx index a66c47b9..d41f01c2 100644 --- a/CommunityToolkit.Aspire.slnx +++ b/CommunityToolkit.Aspire.slnx @@ -157,6 +157,10 @@ + + + + @@ -198,6 +202,7 @@ + @@ -250,6 +255,7 @@ + diff --git a/examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.Api/CommunityToolkit.Aspire.Hosting.Stripe.Api.csproj b/examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.Api/CommunityToolkit.Aspire.Hosting.Stripe.Api.csproj new file mode 100644 index 00000000..375cccf7 --- /dev/null +++ b/examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.Api/CommunityToolkit.Aspire.Hosting.Stripe.Api.csproj @@ -0,0 +1,3 @@ + + + diff --git a/examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.Api/Program.cs b/examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.Api/Program.cs new file mode 100644 index 00000000..358930ff --- /dev/null +++ b/examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.Api/Program.cs @@ -0,0 +1,22 @@ +var builder = WebApplication.CreateBuilder(args); + +var app = builder.Build(); + +app.MapGet("/", () => "Stripe Webhook API"); + +app.MapPost("/payments/stripe-webhook", async (HttpContext context, IConfiguration configuration) => +{ + var webhookSecret = configuration["STRIPE_WEBHOOK_SECRET"]; + + using var reader = new StreamReader(context.Request.Body); + var json = await reader.ReadToEndAsync(); + + // In a real application, you would verify the webhook signature here + // using the STRIPE_WEBHOOK_SECRET + + return Results.Ok(new { received = true, hasSecret = !string.IsNullOrEmpty(webhookSecret) }); +}); + +app.MapGet("/health", () => Results.Ok()); + +app.Run(); diff --git a/examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.Api/Properties/launchSettings.json b/examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.Api/Properties/launchSettings.json new file mode 100644 index 00000000..599e75e8 --- /dev/null +++ b/examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.Api/Properties/launchSettings.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17227;http://localhost:15019", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15019", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} + diff --git a/examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.AppHost/CommunityToolkit.Aspire.Hosting.Stripe.AppHost.csproj b/examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.AppHost/CommunityToolkit.Aspire.Hosting.Stripe.AppHost.csproj new file mode 100644 index 00000000..ab727dc6 --- /dev/null +++ b/examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.AppHost/CommunityToolkit.Aspire.Hosting.Stripe.AppHost.csproj @@ -0,0 +1,13 @@ + + + + Exe + 7e518d7d-87e8-4337-8806-1c99acce5e01 + + + + + + + + diff --git a/examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.AppHost/Program.cs b/examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.AppHost/Program.cs new file mode 100644 index 00000000..c0d1381e --- /dev/null +++ b/examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.AppHost/Program.cs @@ -0,0 +1,15 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var api = builder.AddProject("api"); + +// Provide a development default; override via configuration in real projects. +var stripeApiKey = builder.AddParameter("stripe-api-key", secret: true); + +// Forward Stripe webhooks to the API's webhook endpoint +var stripe = builder.AddStripe("stripe", stripeApiKey) + .WithListen(api); + +// The API will receive the webhook signing secret via STRIPE_WEBHOOK_SECRET environment variable +api.WithReference(stripe); + +builder.Build().Run(); diff --git a/examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.AppHost/Properties/launchSettings.json b/examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000..4dca3055 --- /dev/null +++ b/examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17217;http://localhost:15269", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:22180", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22179" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15269", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19031", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20128" + } + } + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Stripe/CommunityToolkit.Aspire.Hosting.Stripe.csproj b/src/CommunityToolkit.Aspire.Hosting.Stripe/CommunityToolkit.Aspire.Hosting.Stripe.csproj new file mode 100644 index 00000000..e0caed34 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Stripe/CommunityToolkit.Aspire.Hosting.Stripe.csproj @@ -0,0 +1,12 @@ + + + + hosting stripe payments webhooks + A .NET Aspire integration for the Stripe CLI for local webhook forwarding and testing. + + + + + + + diff --git a/src/CommunityToolkit.Aspire.Hosting.Stripe/README.md b/src/CommunityToolkit.Aspire.Hosting.Stripe/README.md new file mode 100644 index 00000000..53ad2c51 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Stripe/README.md @@ -0,0 +1,123 @@ +# CommunityToolkit.Aspire.Hosting.Stripe library + +Provides extension methods and resource definitions for a .NET Aspire AppHost to configure the Stripe CLI for local webhook forwarding and testing. + +## Getting Started + +### Prerequisites + +The Stripe CLI must be installed on your machine. You can install it by following the [official Stripe CLI installation guide](https://stripe.com/docs/stripe-cli#install). + +### Install the package + +In your AppHost project, install the package using the following command: + +```dotnetcli +dotnet add package CommunityToolkit.Aspire.Hosting.Stripe +``` + +### Example usage + +Then, in the _Program.cs_ file of your AppHost project, add the Stripe CLI and configure it to forward webhooks to your API: + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +var stripeApiKey = builder.AddParameter("stripe-api-key", "sk_test_default", secret: true); // Override for real keys + +var externalEndpoint = builder.AddExternalService("webhook-endpoint", "http://localhost:5082"); +var stripe = builder.AddStripe("stripe", stripeApiKey) + .WithListen(externalEndpoint, webhookPath: "/payments/stripe-webhook"); + +var api = builder.AddProject("api") + .WithReference(stripe); + +builder.Build().Run(); +``` + +This will: + +1. Start the Stripe CLI listening for webhook events +2. Forward all webhook events to `http://localhost:5082/payments/stripe-webhook` +3. Provide the Stripe API key to the container via the `STRIPE_API_KEY` environment variable +4. Make the webhook signing secret available to the API project via the `STRIPE_WEBHOOK_SECRET` environment variable + +### Forwarding to an Aspire endpoint + +You can also construct URLs dynamically using Aspire endpoint references: + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +var api = builder.AddProject("api") + .WithHttpEndpoint(port: 5082, name: "http"); + +var stripeApiKey = builder.AddParameter("stripe-api-key", "sk_test_default", secret: true); + +var stripe = builder.AddStripe("stripe", stripeApiKey) + .WithListen(api, webhookPath: "/payments/stripe-webhook", + events: ["payment_intent.created", "charge.succeeded"]); + +api.WithReference(stripe); + +builder.Build().Run(); +``` + +Note: When constructing URLs with paths, you need to use `ReferenceExpression.Create` to combine the endpoint URL with your webhook path. + +### Using a custom environment variable for the webhook secret + +By default, the webhook signing secret is exposed as `STRIPE_WEBHOOK_SECRET`. You can customize this: + +```csharp +var api = builder.AddProject("api") + .WithReference(stripe, webhookSigningSecretEnvVarName: "STRIPE_SECRET"); +``` + +### Configuring API key + +`AddStripe` requires an `IResourceBuilder` that supplies your Stripe API key. The value is exposed to the container as the `STRIPE_API_KEY` environment variable. You can optionally reuse the same parameter to add the `--api-key` command-line argument: + +```csharp +var apiKey = builder.AddParameter("stripe-api-key", "sk_test_default", secret: true); + +var webhookEndpoint = builder.AddExternalService("webhook-endpoint", "http://localhost:5082"); +var stripe = builder.AddStripe("stripe", apiKey) + .WithListen(webhookEndpoint, webhookPath: "/webhooks") + .WithApiKey(apiKey); // optional: forwards the key to the CLI via --api-key +``` + +### Filtering events + +You can filter which webhook events the Stripe CLI listens for: + +```csharp +var stripeApiKey = builder.AddParameter("stripe-api-key", "sk_test_default", secret: true); +var webhookEndpoint = builder.AddExternalService("webhook-endpoint", "http://localhost:5082"); +var stripe = builder.AddStripe("stripe", stripeApiKey) + .WithListen(webhookEndpoint, webhookPath: "/webhooks", + events: ["payment_intent.created", "charge.succeeded"]); +``` + +## How it works + +The Stripe CLI integration: + +- Runs `stripe listen --forward-to ` to listen for webhook events from Stripe's test environment +- Forwards those events to your local application endpoint +- Exposes the webhook signing secret so your application can verify webhook authenticity +- Provides a development-friendly way to test webhook integrations without deploying to production + +## Additional Information + +For more information about the Stripe CLI: + +- [Stripe CLI Documentation](https://stripe.com/docs/stripe-cli) +- [Testing webhooks locally](https://stripe.com/docs/webhooks/test) + +https://learn.microsoft.com/dotnet/aspire/community-toolkit/hosting-stripe + +## Feedback & contributing + +https://github.com/CommunityToolkit/Aspire + diff --git a/src/CommunityToolkit.Aspire.Hosting.Stripe/StripeContainerImageTags.cs b/src/CommunityToolkit.Aspire.Hosting.Stripe/StripeContainerImageTags.cs new file mode 100644 index 00000000..7d2090b5 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Stripe/StripeContainerImageTags.cs @@ -0,0 +1,11 @@ +namespace CommunityToolkit.Aspire.Hosting.Stripe; + +internal static class StripeContainerImageTags +{ + /// docker.io + public const string Registry = "docker.io"; + /// stripe/stripe-cli + public const string Image = "stripe/stripe-cli"; + /// v1.33.0 + public const string Tag = "v1.33.0"; +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Stripe/StripeExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Stripe/StripeExtensions.cs new file mode 100644 index 00000000..4cb83ae2 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Stripe/StripeExtensions.cs @@ -0,0 +1,303 @@ +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.Stripe; +using Microsoft.Extensions.DependencyInjection; + +using System.Diagnostics.CodeAnalysis; + +namespace Aspire.Hosting; + +/// +/// Extension methods for adding Stripe CLI to a . +/// +public static class StripeExtensions +{ + /// + /// Adds the Stripe CLI to the application model for local webhook forwarding. + /// + /// The to add the resource to. + /// The name of the resource. + /// The parameter builder providing the Stripe API key. + /// A reference to the . + public static IResourceBuilder AddStripe( + this IDistributedApplicationBuilder builder, + [ResourceName] string name, + IResourceBuilder apiKey) + { + ArgumentNullException.ThrowIfNull(builder, nameof(builder)); + ArgumentException.ThrowIfNullOrEmpty(name, nameof(name)); + ArgumentNullException.ThrowIfNull(apiKey, nameof(apiKey)); + + StripeResource resource = new(name); + + return builder.AddResource(resource) + .WithImage(StripeContainerImageTags.Image) + .WithImageTag(StripeContainerImageTags.Tag) + .WithImageRegistry(StripeContainerImageTags.Registry) + .WithEnvironment(context => + { + context.EnvironmentVariables.Add("STRIPE_API_KEY", ReferenceExpression.Create($"{apiKey}")); + }) + .ExcludeFromManifest(); + } + + /// + /// Configures the Stripe CLI to listen for webhooks and forward them to the specified URL expression. + /// + /// The resource builder. + /// The resource to forward webhooks to. + /// The path to the webhook endpoint. + /// Optional collection of specific webhook events to listen for (e.g., ["payment_intent.created", "charge.succeeded"]). If not specified, all events are forwarded. + /// A reference to the . + public static IResourceBuilder WithListen( + this IResourceBuilder builder, + IResourceBuilder forwardTo, + string webhookPath = "/webhooks/stripe", + IEnumerable? events = null) + { + ArgumentNullException.ThrowIfNull(builder, nameof(builder)); + ArgumentNullException.ThrowIfNull(forwardTo, nameof(forwardTo)); + + builder.WithArgs("listen"); + builder.WithArgs(context => + { + if (!forwardTo.Resource.TryGetEndpoints(out var endpoints) || !endpoints.Any()) + { + throw new InvalidOperationException($"The resource '{forwardTo.Resource.Name}' does not have any endpoints defined."); + } + context.Args.Add("--forward-to"); + context.Args.Add($"{endpoints.First().AllocatedEndpoint}{webhookPath}"); + }); + + if (events is not null && events.Any()) + { + builder.WithArgs("--events", string.Join(",", events)); + } + + return builder.ResolveSecret(); + } + + /// + /// Configures the Stripe CLI to listen for webhooks and forward them to the specified URL expression. + /// + /// The resource builder. + /// The resource to forward webhooks to. + /// The path to the webhook endpoint. + /// Optional collection of specific webhook events to listen for (e.g., ["payment_intent.created", "charge.succeeded"]). If not specified, all events are forwarded. + /// A reference to the . + public static IResourceBuilder WithListen( + this IResourceBuilder builder, + IResourceBuilder forwardTo, + string webhookPath = "/webhooks/stripe", + IEnumerable? events = null) + { + ArgumentNullException.ThrowIfNull(builder, nameof(builder)); + ArgumentNullException.ThrowIfNull(forwardTo, nameof(forwardTo)); + + builder.WithArgs("listen"); + + if (forwardTo.Resource.Uri is not null) + { + builder.WithArgs($"--forward-to"); + builder.WithArgs(ReferenceExpression.Create($"{forwardTo.Resource.Uri.ToString()}{webhookPath}")); + } + else if (forwardTo.Resource.UrlParameter is not null) + { + builder.WithArgs(async context => + { + string? url = await forwardTo.Resource.UrlParameter.GetValueAsync(context.CancellationToken).ConfigureAwait(false); + if (!context.ExecutionContext.IsPublishMode) + { + if (!UrlIsValidForExternalService(url, out var _, out var message)) + { + throw new DistributedApplicationException($"The URL parameter '{forwardTo.Resource.UrlParameter.Name}' for the external service '{forwardTo.Resource.Name}' is invalid: {message}"); + } + } + context.Args.Add($"--forward-to"); + context.Args.Add(ReferenceExpression.Create($"{url}{webhookPath}")); + }); + } + else + { + throw new InvalidOperationException($"The external service resource '{forwardTo.Resource.Name}' does not have a defined URI."); + } + + if (events is not null && events.Any()) + { + builder.WithArgs("--events", string.Join(",", events)); + } + + return builder.ResolveSecret(); + } + + /// + /// Configures the Stripe CLI to use a specific API key from a parameter. + /// + /// The resource builder. + /// The parameter containing the Stripe API key to use. + /// A reference to the . + public static IResourceBuilder WithApiKey( + this IResourceBuilder builder, + IResourceBuilder apiKey) + { + ArgumentNullException.ThrowIfNull(builder, nameof(builder)); + ArgumentNullException.ThrowIfNull(apiKey, nameof(apiKey)); + + return builder.WithArgs(context => + { + context.Args.Add($"--api-key"); + context.Args.Add(apiKey); + }); + } + + /// + /// Adds a reference to a Stripe CLI resource for accessing its webhook signing secret. + /// + /// The resource builder. + /// The Stripe CLI resource to reference. + /// Optional environment variable name to use for the webhook signing secret. Defaults to "STRIPE_WEBHOOK_SECRET". + /// A reference to the . + public static IResourceBuilder WithReference( + this IResourceBuilder builder, + IResourceBuilder source, + string webhookSigningSecretEnvVarName = "STRIPE_WEBHOOK_SECRET") + where TDestination : IResourceWithEnvironment + { + ArgumentNullException.ThrowIfNull(builder, nameof(builder)); + ArgumentNullException.ThrowIfNull(source, nameof(source)); + ArgumentException.ThrowIfNullOrEmpty(webhookSigningSecretEnvVarName, nameof(webhookSigningSecretEnvVarName)); + + if (builder is IResourceBuilder waitResource) + { + waitResource.WaitFor(source); + } + + return builder.WithEnvironment(context => + { + context.EnvironmentVariables.Add(webhookSigningSecretEnvVarName, ReferenceExpression.Create($"{source.Resource.WebhookSigningSecret}")); + }); + } + + private static IResourceBuilder ResolveSecret(this IResourceBuilder builder) + { + builder.OnBeforeResourceStarted((resource, @event, ct) => + { + return Task.Run(async () => + { + var notificationService = @event.Services.GetRequiredService(); + var loggerService = @event.Services.GetRequiredService(); + + await foreach (var resourceEvent in notificationService.WatchAsync(ct).ConfigureAwait(false)) + { + if (!string.Equals(resource.Name, resourceEvent.Resource.Name, StringComparison.InvariantCultureIgnoreCase)) + { + continue; + } + + _ = WatchResourceLogsAsync(resourceEvent.ResourceId, loggerService, ct); + break; + } + }, ct); + + async Task WatchResourceLogsAsync(string resourceId, ResourceLoggerService loggerService, CancellationToken cancellationToken) + { + try + { + await foreach (var logEvent in loggerService.WatchAsync(resourceId).WithCancellation(cancellationToken).ConfigureAwait(false)) + { + foreach (var line in logEvent.Where(l => !string.IsNullOrWhiteSpace(l.Content))) + { + if (TryExtractSigningSecret(line.Content, out var signingSecret)) + { + resource.WebhookSigningSecret = signingSecret; + return; + } + } + } + } + catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Expected when the resource is shutting down. + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Expected when the resource is shutting down. + } + } + + static bool TryExtractSigningSecret(string? content, out string? secret) + { + secret = null; + + if (string.IsNullOrWhiteSpace(content)) + { + return false; + } + + const string Prefix = "whsec_"; + var startIndex = content.IndexOf(Prefix, StringComparison.OrdinalIgnoreCase); + if (startIndex < 0) + { + return false; + } + + var endIndex = startIndex + Prefix.Length; + while (endIndex < content.Length && IsSecretCharacter(content[endIndex])) + { + endIndex++; + } + + var candidate = content.Substring(startIndex, endIndex - startIndex).TrimEnd('.', ';', ',', ')', '"'); + + if (candidate.Length <= Prefix.Length) + { + return false; + } + + secret = candidate; + return true; + + static bool IsSecretCharacter(char value) => char.IsLetterOrDigit(value) || value is '_' or '-'; + } + }); + + return builder; + } + + internal static bool UrlIsValidForExternalService(string? url, [NotNullWhen(true)] out Uri? uri, [NotNullWhen(false)] out string? message) + { + if (url is null || !Uri.TryCreate(url, UriKind.Absolute, out uri)) + { + uri = null; + message = "The URL for the external service must be an absolute URI."; + return false; + } + + if (GetUriValidationException(uri) is { } exception) + { + message = exception.Message; + uri = null; + return false; + } + + message = null; + + return true; + } + + private static ArgumentException? GetUriValidationException(Uri uri) + { + if (!uri.IsAbsoluteUri) + { + return new ArgumentException("The URI for the external service must be absolute.", nameof(uri)); + } + if (uri.AbsolutePath != "/") + { + return new ArgumentException("The URI absolute path must be \"/\".", nameof(uri)); + } + if (!string.IsNullOrEmpty(uri.Fragment)) + { + return new ArgumentException("The URI cannot contain a fragment.", nameof(uri)); + } + return null; + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Stripe/StripeResource.cs b/src/CommunityToolkit.Aspire.Hosting.Stripe/StripeResource.cs new file mode 100644 index 00000000..671507e9 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Stripe/StripeResource.cs @@ -0,0 +1,13 @@ +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents a Stripe CLI container resource for local webhook forwarding and testing. +/// +/// The name of the resource. +public class StripeResource(string name) : ContainerResource(name) +{ + /// + /// Gets the webhook signing secret retrieved from the Stripe CLI. + /// + public string? WebhookSigningSecret { get; internal set; } +} \ No newline at end of file diff --git a/tests/CommunityToolkit.Aspire.Hosting.Stripe.Tests/AddStripeTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Stripe.Tests/AddStripeTests.cs new file mode 100644 index 00000000..5982c766 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Stripe.Tests/AddStripeTests.cs @@ -0,0 +1,325 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.Stripe; + +namespace CommunityToolkit.Aspire.Hosting.Stripe.Tests; + +public class AddStripeTests +{ + private const string TestApiKeyValue = "sk_test_123"; + + [Fact] + public void StripeConfiguresContainerImage() + { + var builder = DistributedApplication.CreateBuilder(); + var apiKey = builder.AddParameter("stripe-api-key", TestApiKeyValue); + + builder.AddStripe("stripe", apiKey); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + + var containerAnnotation = Assert.Single(resource.Annotations.OfType()); + Assert.Equal(StripeContainerImageTags.Image, containerAnnotation.Image); + Assert.Equal(StripeContainerImageTags.Tag, containerAnnotation.Tag); + Assert.Equal(StripeContainerImageTags.Registry, containerAnnotation.Registry); + } + + [Fact] + public async Task StripeWithListenAddsListenArgs() + { + var builder = DistributedApplication.CreateBuilder(); + var apiKey = builder.AddParameter("stripe-api-key", TestApiKeyValue); + + var externalEndpoint = builder.AddExternalService("external-api", "http://localhost:5082"); + + builder.AddStripe("stripe", apiKey) + .WithListen(externalEndpoint, webhookPath: "webhooks"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + + var args = await resource.GetArgumentValuesAsync(); + + Assert.Collection(args, + arg => Assert.Equal("listen", arg), + arg => Assert.Equal("--forward-to", arg), + arg => Assert.Equal("http://localhost:5082/webhooks", arg) + ); + } + + [Fact] + public async Task StripeWithListenAndEventsAddsEventArgs() + { + var builder = DistributedApplication.CreateBuilder(); + var apiKey = builder.AddParameter("stripe-api-key", TestApiKeyValue); + + var externalEndpoint = builder.AddExternalService("external-api", "http://localhost:5082"); + + builder.AddStripe("stripe", apiKey) + .WithListen(externalEndpoint, webhookPath: "webhooks", events: ["payment_intent.created", "charge.succeeded"]); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + + var args = await resource.GetArgumentValuesAsync(); + + Assert.Collection(args, + arg => Assert.Equal("listen", arg), + arg => Assert.Equal("--forward-to", arg), + arg => Assert.Equal("http://localhost:5082/webhooks", arg), + arg => Assert.Equal("--events", arg), + arg => Assert.Equal("payment_intent.created,charge.succeeded", arg) + ); + } + + [Fact] + public async Task StripeWithApiKeyAddsApiKeyArg() + { + var builder = DistributedApplication.CreateBuilder(); + + var externalEndpoint = builder.AddExternalService("external-api", "http://localhost:5082"); + var apiKey = builder.AddParameter("api-key", "sk_test_123"); + + builder.AddStripe("stripe", apiKey) + .WithListen(externalEndpoint) + .WithApiKey(apiKey); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + + var args = await resource.GetArgumentValuesAsync(); + + Assert.Contains(args, arg => arg == "--api-key"); + Assert.Contains(args, arg => arg == "sk_test_123"); + } + + [Fact] + public void StripeWithApiKeyParameterAddsApiKeyArg() + { + var builder = DistributedApplication.CreateBuilder(); + + var apiKey = builder.AddParameter("stripe-api-key", TestApiKeyValue); + + var externalEndpoint = builder.AddExternalService("external-api", "http://localhost:5082"); + + builder.AddStripe("stripe", apiKey) + .WithListen(externalEndpoint) + .WithApiKey(apiKey); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + + // Verify that a CommandLineArgsCallbackAnnotation was added + var argsAnnotation = resource.Annotations.OfType(); + Assert.NotEmpty(argsAnnotation); + } + + [Fact] + public void StripeWithListenToEndpointReference() + { + var builder = DistributedApplication.CreateBuilder(); + var apiKey = builder.AddParameter("stripe-api-key", TestApiKeyValue); + + var api = builder.AddProject("api"); + + builder.AddStripe("stripe", apiKey) + .WithListen(api); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + + // Verify that a CommandLineArgsCallbackAnnotation was added + var argsAnnotation = resource.Annotations.OfType(); + Assert.NotEmpty(argsAnnotation); + } + + [Fact] + public void AddStripeNullBuilderThrows() + { + IDistributedApplicationBuilder builder = null!; + var apiKey = DistributedApplication.CreateBuilder().AddParameter("stripe-api-key", TestApiKeyValue); + + Assert.Throws(() => builder.AddStripe("stripe", apiKey)); + } + + [Fact] + public void AddStripeNullNameThrows() + { + var builder = DistributedApplication.CreateBuilder(); + var apiKey = builder.AddParameter("stripe-api-key", TestApiKeyValue); + + Assert.Throws(() => builder.AddStripe(null!, apiKey)); + } + + [Fact] + public void AddStripeEmptyNameThrows() + { + var builder = DistributedApplication.CreateBuilder(); + var apiKey = builder.AddParameter("stripe-api-key", TestApiKeyValue); + + Assert.Throws(() => builder.AddStripe(""!, apiKey)); + } + + [Fact] + public void WithListenNullBuilderThrows() + { + IResourceBuilder builder = null!; + + Assert.Throws(() => builder.WithListen((IResourceBuilder)null!)); + } + + [Fact] + public void WithListenNullUrlThrows() + { + var builder = DistributedApplication.CreateBuilder(); + var apiKey = builder.AddParameter("stripe-api-key", TestApiKeyValue); + var stripe = builder.AddStripe("stripe", apiKey); + + Assert.Throws(() => stripe.WithListen((IResourceBuilder)null!)); + } + + [Fact] + public void WithListenNullEndpointReferenceThrows() + { + var builder = DistributedApplication.CreateBuilder(); + var apiKey = builder.AddParameter("stripe-api-key", TestApiKeyValue); + var stripe = builder.AddStripe("stripe", apiKey); + + Assert.Throws(() => stripe.WithListen((IResourceBuilder)null!)); + } + + [Fact] + public void WithApiKeyNullBuilderThrows() + { + IResourceBuilder builder = null!; + + var ex = Record.Exception(() => builder.WithApiKey(null!)); + + var aex = Assert.IsType(ex); + Assert.Equal("builder", aex.ParamName); + } + + [Fact] + public void WithApiKeyNullKeyThrows() + { + var builder = DistributedApplication.CreateBuilder(); + var apiKey = builder.AddParameter("stripe-api-key", TestApiKeyValue); + var stripe = builder.AddStripe("stripe", apiKey); + + var ex = Record.Exception(() => stripe.WithApiKey(null!)); + + var aex = Assert.IsType(ex); + Assert.Equal("apiKey", aex.ParamName); + } + + [Fact] + public void WithReferenceAddsWebhookSecretEnvironmentVariable() + { + var builder = DistributedApplication.CreateBuilder(); + var apiKey = builder.AddParameter("stripe-api-key", TestApiKeyValue); + + var api = builder.AddProject("api"); + + var stripe = builder.AddStripe("stripe", apiKey) + .WithListen(api); + + api.WithReference(stripe); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var apiResource = Assert.Single(appModel.Resources.OfType()); + + // Verify that an EnvironmentCallbackAnnotation was added + var envAnnotations = apiResource.Annotations.OfType(); + Assert.NotEmpty(envAnnotations); + } + + [Fact] + public void WithReferenceCustomEnvironmentVariableName() + { + var builder = DistributedApplication.CreateBuilder(); + var apiKey = builder.AddParameter("stripe-api-key", TestApiKeyValue); + + var api = builder.AddProject("api"); + var stripe = builder.AddStripe("stripe", apiKey) + .WithListen(api); + + api.WithReference(stripe, webhookSigningSecretEnvVarName: "CUSTOM_STRIPE_SECRET"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var apiResource = Assert.Single(appModel.Resources.OfType()); + + // Verify that an EnvironmentCallbackAnnotation was added + var envAnnotations = apiResource.Annotations.OfType(); + Assert.NotEmpty(envAnnotations); + } + + [Fact] + public void WithReferenceNullBuilderThrows() + { + var builder = DistributedApplication.CreateBuilder(); + var apiKey = builder.AddParameter("stripe-api-key", TestApiKeyValue); + var stripe = builder.AddStripe("stripe", apiKey); + + IResourceBuilder apiBuilder = null!; + + Assert.Throws(() => apiBuilder.WithReference(stripe)); + } + + [Fact] + public void WithReferenceNullSourceThrows() + { + var builder = DistributedApplication.CreateBuilder(); + var api = builder.AddProject("api"); + + Assert.Throws(() => api.WithReference((IResourceBuilder)null!)); + } + + [Fact] + public void WithReferenceNullEnvVarNameThrows() + { + var builder = DistributedApplication.CreateBuilder(); + + var apiKey = builder.AddParameter("stripe-api-key", TestApiKeyValue); + var stripe = builder.AddStripe("stripe", apiKey); + var api = builder.AddProject("api"); + + Assert.Throws(() => api.WithReference(stripe, webhookSigningSecretEnvVarName: null!)); + } + + [Fact] + public void WithReferenceEmptyEnvVarNameThrows() + { + var builder = DistributedApplication.CreateBuilder(); + + var apiKey = builder.AddParameter("stripe-api-key", TestApiKeyValue); + var stripe = builder.AddStripe("stripe", apiKey); + var api = builder.AddProject("api"); + + Assert.Throws(() => api.WithReference(stripe, webhookSigningSecretEnvVarName: "")); + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Stripe.Tests/AppHostTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Stripe.Tests/AppHostTests.cs new file mode 100644 index 00000000..83901481 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Stripe.Tests/AppHostTests.cs @@ -0,0 +1,39 @@ +using Aspire.Components.Common.Tests; +using CommunityToolkit.Aspire.Testing; + +namespace CommunityToolkit.Aspire.Hosting.Stripe.Tests; + +[RequiresDocker] +public class AppHostTests(AspireIntegrationTestFixture fixture) + : IClassFixture> +{ + [Fact] + [RequiresAuthenticatedTool("stripe")] + public async Task ResourceStartsAndRespondsOk() + { + var resourceName = "api"; + + await fixture.ResourceNotificationService.WaitForResourceHealthyAsync(resourceName) + .WaitAsync(TimeSpan.FromMinutes(1)); + + var httpClient = fixture.CreateHttpClient(resourceName); + + var response = await httpClient.GetAsync("/"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal("Stripe Webhook API", body); + } + + [Fact] + [RequiresAuthenticatedTool("stripe")] + public async Task StripeResourceIsCreated() + { + var app = fixture.App; + var appModel = app.Services.GetRequiredService(); + + var stripeResource = appModel.Resources.OfType().SingleOrDefault(); + Assert.NotNull(stripeResource); + Assert.Equal("stripe", stripeResource.Name); + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Stripe.Tests/CommunityToolkit.Aspire.Hosting.Stripe.Tests.csproj b/tests/CommunityToolkit.Aspire.Hosting.Stripe.Tests/CommunityToolkit.Aspire.Hosting.Stripe.Tests.csproj new file mode 100644 index 00000000..3d0ffba0 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Stripe.Tests/CommunityToolkit.Aspire.Hosting.Stripe.Tests.csproj @@ -0,0 +1,8 @@ + 737bbb5a-133b-43e8-8bb6-b86441528f8e + + + + + + + diff --git a/tests/CommunityToolkit.Aspire.Testing/RequiresAuthenticatedToolAttribute.cs b/tests/CommunityToolkit.Aspire.Testing/RequiresAuthenticatedToolAttribute.cs new file mode 100644 index 00000000..2a343d15 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Testing/RequiresAuthenticatedToolAttribute.cs @@ -0,0 +1,37 @@ +using Aspire.Components.Common.Tests; +using Xunit.Sdk; + +namespace CommunityToolkit.Aspire.Testing; + +/// +/// Marks a test or test class as requiring an authenticated external tool. +/// Adds a trait to propagate the required tool name to the xUnit pipeline. +/// +[TraitDiscoverer("CommunityToolkit.Aspire.Testing.RequiresAuthenticatedToolDiscoverer", "CommunityToolkit.Aspire.Testing")] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] +public sealed class RequiresAuthenticatedToolAttribute : Attribute, ITraitAttribute +{ + /// Initializes a new instance of the class. + /// The name of the external tool required for the test. + /// An optional reason describing why the tool is required. + /// Thrown when is null or whitespace. + public RequiresAuthenticatedToolAttribute(string toolName, string? reason = null) + { + if (string.IsNullOrWhiteSpace(toolName)) + { + throw new ArgumentException("Tool name cannot be null or whitespace.", nameof(toolName)); + } + + ToolName = toolName; + Reason = reason; + } + + /// Gets the name of the external tool required for the test. + public string ToolName { get; } + + /// Gets the optional reason describing why the tool is required. + public string? Reason { get; } + + /// Gets a value indicating whether authenticated tools are supported in the current environment. + public static bool IsSupported => !PlatformDetection.IsRunningOnCI; +} diff --git a/tests/CommunityToolkit.Aspire.Testing/RequiresAuthenticatedToolDiscoverer.cs b/tests/CommunityToolkit.Aspire.Testing/RequiresAuthenticatedToolDiscoverer.cs new file mode 100644 index 00000000..9acb5b20 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Testing/RequiresAuthenticatedToolDiscoverer.cs @@ -0,0 +1,36 @@ +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace CommunityToolkit.Aspire.Testing; + +/// +/// Discovers traits for . +/// Adds a RequiresTools trait with the tool name and +/// categorizes tests as unsupported-platform when authenticated tools are not supported. +/// +public sealed class RequiresAuthenticatedToolDiscoverer : ITraitDiscoverer +{ + /// + public IEnumerable> GetTraits(IAttributeInfo traitAttribute) + { + string? toolName = null; + foreach (object? argument in traitAttribute.GetConstructorArguments().Where(a => a is string)) + { + if (argument is string value) + { + toolName = value; + break; + } + } + + if (!string.IsNullOrWhiteSpace(toolName)) + { + yield return new KeyValuePair("RequiresTools", toolName); + } + + if (!RequiresAuthenticatedToolAttribute.IsSupported) + { + yield return new KeyValuePair("category", "unsupported-platform"); + } + } +}