-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Stripe integration tests #7828
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
justindbaur
wants to merge
4
commits into
main
Choose a base branch
from
stripe-integration-tests
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Stripe integration tests #7828
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
61 changes: 61 additions & 0 deletions
61
test/Billing.IntegrationTest/AddingOrganizationTaxIdTests.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| using System.Net.Http.Json; | ||
| using System.Text.Json.Nodes; | ||
| using Bit.Test.Common.Helpers; | ||
|
|
||
| namespace Bit.Billing.IntegrationTest; | ||
|
|
||
| public class AddingOrganizationTaxIdTests(StripeTestsFixture fixture) : IClassFixture<StripeTestsFixture> | ||
| { | ||
| [BillingFact] | ||
| public async Task BillingAddress_WhenTaxIdProvided_PersistsAndEchoesTaxId() | ||
| { | ||
| var (client, _, organizationId, _) = | ||
| await fixture.PrepareOrganizationOwnerAsync("update-billing-address-tax-id@example.com"); | ||
|
|
||
| // Drives UpdateBillingAddressCommand.UpdateBusinessBillingAddressAsync, which expands | ||
| // tax_ids on the customer update so the existing-tax-id deletion loop can run, then | ||
| // CreateTaxIdAsync attaches the new id and the response carries it back. | ||
| var response = await client.PutAsJsonAsync( | ||
| $"/organizations/{organizationId}/billing/vnext/address", | ||
| new | ||
| { | ||
| Country = "US", | ||
| PostalCode = "10001", | ||
| Line1 = "123 Test St", | ||
| City = "New York", | ||
| State = "NY", | ||
| TaxId = new { Code = "us_ein", Value = "12-3456789" }, | ||
| }); | ||
| await Assert.SuccessResponseAsync(response); | ||
|
|
||
| var billingAddress = (await response.Content.ReadFromJsonAsync<JsonObject>())!; | ||
| Assert.Equal("us_ein", billingAddress["taxId"]!["code"]!.GetValue<string>()); | ||
| Assert.Equal("12-3456789", billingAddress["taxId"]!["value"]!.GetValue<string>()); | ||
| } | ||
|
|
||
| [BillingFact] | ||
| public async Task BillingAddress_AfterTaxIdAdded_GetAddressReturnsTheTaxId() | ||
| { | ||
| var (client, _, organizationId, _) = | ||
| await fixture.PrepareOrganizationOwnerAsync("get-billing-address-tax-id@example.com"); | ||
|
|
||
| var updateResponse = await client.PutAsJsonAsync( | ||
| $"/organizations/{organizationId}/billing/vnext/address", | ||
| new | ||
| { | ||
| Country = "US", | ||
| PostalCode = "10001", | ||
| TaxId = new { Code = "us_ein", Value = "12-3456789" }, | ||
| }); | ||
| await Assert.SuccessResponseAsync(updateResponse); | ||
|
|
||
| // Drives GetBillingAddressQuery.Run business path (Expand=tax_ids), which reads | ||
| // customer.TaxIds.FirstOrDefault() to populate the response. | ||
| var getResponse = await client.GetAsync($"/organizations/{organizationId}/billing/vnext/address"); | ||
| await Assert.SuccessResponseAsync(getResponse); | ||
|
|
||
| var billingAddress = (await getResponse.Content.ReadFromJsonAsync<JsonObject>())!; | ||
| Assert.Equal("us_ein", billingAddress["taxId"]!["code"]!.GetValue<string>()); | ||
| Assert.Equal("12-3456789", billingAddress["taxId"]!["value"]!.GetValue<string>()); | ||
| } | ||
| } |
123 changes: 123 additions & 0 deletions
123
test/Billing.IntegrationTest/AdminApplicationFactory.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| using System.Web; | ||
| using Bit.Admin.Jobs; | ||
| using Bit.Core.Services; | ||
| using Bit.IntegrationTestCommon; | ||
| using Bit.Test.Common.Helpers; | ||
| using Microsoft.AspNetCore.Mvc; | ||
| using Microsoft.AspNetCore.Mvc.Testing; | ||
| using Microsoft.Extensions.Configuration; | ||
| using Microsoft.Extensions.DependencyInjection; | ||
| using NSubstitute; | ||
|
|
||
| namespace Bit.Billing.IntegrationTest; | ||
|
|
||
| /// <summary> | ||
| /// Application factory for the Admin host. Holds an inner | ||
| /// <see cref="WebApplicationFactory{TEntryPoint}"/> privately and exposes intent | ||
| /// methods for the side-effects (passwordless sign-in tokens, business-unit | ||
| /// conversion invite tokens) that would otherwise leave the process as emails. | ||
| /// Tokens are recovered after each request by inspecting the substituted | ||
| /// <see cref="IMailService"/>'s recorded calls — no captured state on the factory. | ||
| /// </summary> | ||
| public sealed class AdminApplicationFactory : IAsyncDisposable | ||
| { | ||
| private readonly WebApplicationFactory<Admin.Program> _factory; | ||
|
|
||
| public AdminApplicationFactory(ITestDatabase testDatabase) | ||
| { | ||
| _factory = new WebApplicationFactory<Admin.Program>().WithWebHostBuilder(builder => | ||
| { | ||
| builder.ConfigureAppConfiguration((_, config) => | ||
| { | ||
| var configValues = new Dictionary<string, string?>(); | ||
| testDatabase.ModifyGlobalSettings(configValues); | ||
| config.AddInMemoryCollection(configValues); | ||
| }); | ||
|
|
||
| builder.ConfigureServices(services => | ||
| { | ||
| services.AddSingleton(Substitute.For<IMailService>()); | ||
|
|
||
| // Remove Quartz hosted jobs to avoid concurrent startup issues | ||
| var jobHostedServiceDescriptor = services.Single(sd => sd.ImplementationType == typeof(JobsHostedService)); | ||
| services.Remove(jobHostedServiceDescriptor); | ||
|
|
||
| // Turn off antiforgery application-wide so tests don't have to | ||
| // mint or thread CSRF tokens through every form post. | ||
| services.PostConfigure<MvcOptions>(options => | ||
| { | ||
| options.Filters.Add(new IgnoreAntiforgeryTokenAttribute { Order = 1001 }); | ||
| }); | ||
|
|
||
| testDatabase.AddDatabase(services); | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Signs into the Admin Portal using the passwordless flow and returns a | ||
| /// client whose cookies carry an authenticated admin session. | ||
| /// </summary> | ||
| public async Task<HttpClient> SignInAdminAsync() | ||
| { | ||
| const string Email = "admin@localhost"; | ||
| var client = _factory.CreateClient(); | ||
| var mailService = _factory.Services.GetRequiredService<IMailService>(); | ||
|
|
||
| var loginResponse = await client.PostAsync("/login", new FormUrlEncodedContent(new Dictionary<string, string> | ||
| { | ||
| { "Email", Email }, | ||
| })); | ||
|
justindbaur marked this conversation as resolved.
Dismissed
|
||
| await Assert.SuccessResponseAsync(loginResponse); | ||
|
|
||
| var token = mailService.ReceivedCalls() | ||
| .Where(c => c.GetMethodInfo().Name == nameof(IMailService.SendPasswordlessSignInAsync)) | ||
| .Select(c => (string?)c.GetArguments()[1]) | ||
| .LastOrDefault(); | ||
|
|
||
| if (string.IsNullOrEmpty(token)) | ||
| { | ||
| Assert.Fail($"Admin sign-in token was not captured for {Email}."); | ||
| } | ||
|
|
||
| var confirmResponse = await client.GetAsync( | ||
| $"/login/confirm?email={HttpUtility.UrlEncode(Email)}&token={HttpUtility.UrlEncode(token)}&returnUrl=%2F"); | ||
| await Assert.SuccessResponseAsync(confirmResponse); | ||
|
|
||
| return client; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Posts to the Admin business-unit conversion endpoint for the given | ||
| /// organization and returns the invitation token that would otherwise have | ||
| /// been emailed to the provider admin. | ||
| /// </summary> | ||
| public async Task<string> InitializeBusinessUnitConversionAsync( | ||
| HttpClient adminSession, Guid organizationId, string providerAdminEmail) | ||
| { | ||
| var mailService = _factory.Services.GetRequiredService<IMailService>(); | ||
|
|
||
| var response = await adminSession.PostAsync( | ||
| $"/organizations/billing/{organizationId}/business-unit", | ||
| new FormUrlEncodedContent(new Dictionary<string, string?> | ||
| { | ||
| { "ProviderAdminEmail", providerAdminEmail }, | ||
| })); | ||
|
justindbaur marked this conversation as resolved.
Dismissed
|
||
| await Assert.SuccessResponseAsync(response); | ||
|
|
||
| var token = mailService.ReceivedCalls() | ||
| .Where(c => c.GetMethodInfo().Name == nameof(IMailService.SendBusinessUnitConversionInviteAsync)) | ||
| .Where(c => (string?)c.GetArguments()[2] == providerAdminEmail) | ||
| .Select(c => (string?)c.GetArguments()[1]) | ||
| .LastOrDefault(); | ||
|
|
||
| if (string.IsNullOrEmpty(token)) | ||
| { | ||
| Assert.Fail($"No business-unit conversion token captured for {providerAdminEmail}."); | ||
| } | ||
|
|
||
| return token; | ||
| } | ||
|
|
||
| public ValueTask DisposeAsync() => _factory.DisposeAsync(); | ||
| } | ||
33 changes: 33 additions & 0 deletions
33
test/Billing.IntegrationTest/Billing.IntegrationTest.csproj
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| <Project Sdk="Microsoft.NET.Sdk"> | ||
|
|
||
| <PropertyGroup> | ||
| <ImplicitUsings>enable</ImplicitUsings> | ||
| <Nullable>enable</Nullable> | ||
| </PropertyGroup> | ||
|
|
||
| <ItemGroup> | ||
| <Using Include="Xunit" /> | ||
| </ItemGroup> | ||
|
|
||
| <ItemGroup> | ||
| <PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" /> | ||
| <PackageReference Include="xunit" Version="$(XUnitVersion)" /> | ||
| <PackageReference Include="xunit.runner.visualstudio" Version="$(XUnitRunnerVisualStudioVersion)"> | ||
| <PrivateAssets>all</PrivateAssets> | ||
| <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> | ||
| </PackageReference> | ||
| <PackageReference Include="coverlet.collector" Version="$(CoverletCollectorVersion)"> | ||
| <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||
| <PrivateAssets>all</PrivateAssets> | ||
| </PackageReference> | ||
| <PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" /> | ||
| </ItemGroup> | ||
|
|
||
| <ItemGroup> | ||
| <ProjectReference Include="..\..\src\Admin\Admin.csproj" /> | ||
| <ProjectReference Include="..\..\src\Billing\Billing.csproj" /> | ||
| <ProjectReference Include="..\Api.IntegrationTest\Api.IntegrationTest.csproj" /> | ||
| <ProjectReference Include="..\Common\Common.csproj" /> | ||
| </ItemGroup> | ||
|
|
||
| </Project> |
126 changes: 126 additions & 0 deletions
126
test/Billing.IntegrationTest/BillingApplicationFactory.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| using System.Security.Cryptography; | ||
| using System.Text; | ||
| using System.Text.Json.Nodes; | ||
| using Bit.IntegrationTestCommon; | ||
| using Bit.Test.Common.Helpers; | ||
| using Microsoft.AspNetCore.Hosting; | ||
| using Microsoft.AspNetCore.Mvc.Testing; | ||
| using Microsoft.Extensions.Configuration; | ||
| using Microsoft.Extensions.DependencyInjection.Extensions; | ||
| using Microsoft.Extensions.Hosting; | ||
|
|
||
| namespace Bit.Billing.IntegrationTest; | ||
|
|
||
| /// <summary> | ||
| /// Application factory for the Bit.Billing webhook host. Holds an inner | ||
| /// <see cref="WebApplicationFactory{TEntryPoint}"/> privately, shares the | ||
| /// API host's database, and exposes a single intent method | ||
| /// (<see cref="SendStripeWebhookAsync"/>) that builds a valid signed Stripe | ||
| /// event payload and posts it to the controller — tests interact only through | ||
| /// that method. | ||
| /// </summary> | ||
| public sealed class BillingApplicationFactory : IAsyncDisposable | ||
| { | ||
| public const string WebhookKey = "test-webhook-key"; | ||
| public const string WebhookSecret = "whsec_billing_integration_test_secret_value"; | ||
|
|
||
| /// <summary> | ||
| /// Matches the API version the SDK is pinned to (see | ||
| /// <see cref="Stripe.StripeConfiguration.ApiVersion"/>). The webhook controller | ||
| /// rejects events whose <c>api_version</c> doesn't match this value. | ||
| /// </summary> | ||
| public static string SupportedStripeApiVersion => Stripe.StripeConfiguration.ApiVersion; | ||
|
|
||
| private readonly WebApplicationFactory<Bit.Billing.Program> _factory; | ||
|
|
||
| public BillingApplicationFactory(ITestDatabase testDatabase) | ||
| { | ||
| _factory = new WebApplicationFactory<Bit.Billing.Program>().WithWebHostBuilder(builder => | ||
|
justindbaur marked this conversation as resolved.
Dismissed
|
||
| { | ||
| builder.UseEnvironment(Environments.Development); | ||
|
|
||
| builder.ConfigureAppConfiguration((_, config) => | ||
| { | ||
| var configValues = new Dictionary<string, string?> | ||
| { | ||
| ["BillingSettings:StripeWebhookKey"] = WebhookKey, | ||
| ["BillingSettings:StripeWebhookSecret20250827Basil"] = WebhookSecret, | ||
| }; | ||
| testDatabase.ModifyGlobalSettings(configValues); | ||
| config.AddInMemoryCollection(configValues); | ||
| }); | ||
|
|
||
| builder.ConfigureServices(services => | ||
| { | ||
| // Drop the Quartz-backed jobs hosted service — it spins up a scheduler we | ||
| // don't need (and that throws under parallel test execution). | ||
| var jobsHostedService = services.FirstOrDefault(sd => | ||
| sd.ServiceType == typeof(IHostedService) | ||
| && sd.ImplementationType?.FullName == "Bit.Billing.Jobs.JobsHostedService"); | ||
| if (jobsHostedService != null) | ||
| { | ||
| services.Remove(jobsHostedService); | ||
| } | ||
|
|
||
| services.RemoveAll<Quartz.IScheduler>(); | ||
|
|
||
| testDatabase.AddDatabase(services); | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Builds a Stripe-signed event with the given <paramref name="eventType"/> and | ||
| /// <paramref name="dataObject"/>, posts it to <c>POST /stripe/webhook</c>, and | ||
| /// asserts a successful response. The handler for the event type re-fetches the | ||
| /// underlying object from Stripe with the production Expand list — that fetch is | ||
| /// what the test exercises. | ||
| /// </summary> | ||
| public async Task SendStripeWebhookAsync(string eventType, JsonObject dataObject, string eventId) | ||
| { | ||
| var payload = new JsonObject | ||
| { | ||
| ["id"] = eventId, | ||
| ["object"] = "event", | ||
| ["api_version"] = SupportedStripeApiVersion, | ||
| ["created"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), | ||
| ["livemode"] = false, | ||
| ["pending_webhooks"] = 0, | ||
| ["type"] = eventType, | ||
| ["data"] = new JsonObject | ||
| { | ||
| ["object"] = dataObject, | ||
| }, | ||
| ["request"] = new JsonObject | ||
| { | ||
| ["id"] = $"req_{Guid.NewGuid():N}", | ||
| ["idempotency_key"] = null, | ||
| }, | ||
| }; | ||
|
|
||
| var json = payload.ToJsonString(); | ||
| var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); | ||
| var signature = ComputeStripeSignature(timestamp, json, WebhookSecret); | ||
|
|
||
| using var client = _factory.CreateClient(); | ||
| var request = new HttpRequestMessage(HttpMethod.Post, $"/stripe/webhook?key={WebhookKey}") | ||
| { | ||
| Content = new StringContent(json, Encoding.UTF8, "application/json"), | ||
| }; | ||
|
justindbaur marked this conversation as resolved.
Dismissed
|
||
| request.Headers.TryAddWithoutValidation("Stripe-Signature", $"t={timestamp},v1={signature}"); | ||
|
|
||
| var response = await client.SendAsync(request); | ||
| await Assert.SuccessResponseAsync(response); | ||
| } | ||
|
|
||
| public ValueTask DisposeAsync() => _factory.DisposeAsync(); | ||
|
|
||
| private static string ComputeStripeSignature(long timestamp, string payload, string secret) | ||
| { | ||
| // Stripe webhook signature scheme (v1): HMAC-SHA256(secret, "{timestamp}.{payload}") as lowercase hex. | ||
| var signedPayload = $"{timestamp}.{payload}"; | ||
| using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); | ||
| var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(signedPayload)); | ||
| return Convert.ToHexStringLower(hash); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| namespace Bit.Billing.IntegrationTest; | ||
|
|
||
| /// <summary> | ||
| /// A fact that runs only when <c>RUN_STRIPE_INTEGRATION_TESTS</c> is set in the environment. | ||
| /// These tests hit a real Stripe account, so they are opt-in to avoid running in CI. | ||
| /// </summary> | ||
| public sealed class BillingFactAttribute : FactAttribute | ||
| { | ||
| public BillingFactAttribute() | ||
| { | ||
| if (Environment.GetEnvironmentVariable("RUN_STRIPE_INTEGRATION_TESTS") is null) | ||
| { | ||
| Skip = "Manual only — set RUN_STRIPE_INTEGRATION_TESTS to run."; | ||
| } | ||
| } | ||
| } |
15 changes: 15 additions & 0 deletions
15
test/Billing.IntegrationTest/BusinessUnitConversionTests.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| using Bit.Test.Common.Helpers; | ||
|
|
||
| namespace Bit.Billing.IntegrationTest; | ||
|
|
||
| public class BusinessUnitConversionTests(StripeTestsFixture fixture) : IClassFixture<StripeTestsFixture> | ||
| { | ||
| [BillingFact] | ||
| public async Task ConvertingOrganizationToBusinessUnit_ProviderWarnings_Succeed() | ||
| { | ||
| var (client, providerId) = await fixture.PrepareProviderAdminAsync("provider@example.com"); | ||
|
|
||
| var warningsResponse = await client.GetAsync($"/providers/{providerId}/billing/vnext/warnings"); | ||
| await Assert.SuccessResponseAsync(warningsResponse); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.