diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 9081600c712a..78e7bb580001 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -92,6 +92,7 @@ bitwarden_license/src/test/Scim.ScimTest @bitwarden/team-admin-console-dev
**/*invoice* @bitwarden/team-billing-dev
**/*OrganizationLicense* @bitwarden/team-billing-dev
**/Billing @bitwarden/team-billing-dev
+test/Billing.IntegrationTest @bitwarden/team-billing-dev
**/AppHost @bitwarden/team-billing-dev @bitwarden/dept-architecture # joint ownership
src/Admin/Controllers/ToolsController.cs @bitwarden/team-billing-dev
src/Admin/Views/Tools @bitwarden/team-billing-dev
diff --git a/bitwarden-server.slnx b/bitwarden-server.slnx
index 28f4406b31fc..467e7812d40b 100644
--- a/bitwarden-server.slnx
+++ b/bitwarden-server.slnx
@@ -51,6 +51,7 @@
+
diff --git a/test/Billing.IntegrationTest/AddingOrganizationTaxIdTests.cs b/test/Billing.IntegrationTest/AddingOrganizationTaxIdTests.cs
new file mode 100644
index 000000000000..9a09611d0aa5
--- /dev/null
+++ b/test/Billing.IntegrationTest/AddingOrganizationTaxIdTests.cs
@@ -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
+{
+ [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())!;
+ Assert.Equal("us_ein", billingAddress["taxId"]!["code"]!.GetValue());
+ Assert.Equal("12-3456789", billingAddress["taxId"]!["value"]!.GetValue());
+ }
+
+ [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())!;
+ Assert.Equal("us_ein", billingAddress["taxId"]!["code"]!.GetValue());
+ Assert.Equal("12-3456789", billingAddress["taxId"]!["value"]!.GetValue());
+ }
+}
diff --git a/test/Billing.IntegrationTest/AdminApplicationFactory.cs b/test/Billing.IntegrationTest/AdminApplicationFactory.cs
new file mode 100644
index 000000000000..75b3e1dde80b
--- /dev/null
+++ b/test/Billing.IntegrationTest/AdminApplicationFactory.cs
@@ -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;
+
+///
+/// Application factory for the Admin host. Holds an inner
+/// 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
+/// 's recorded calls — no captured state on the factory.
+///
+public sealed class AdminApplicationFactory : IAsyncDisposable
+{
+ private readonly WebApplicationFactory _factory;
+
+ public AdminApplicationFactory(ITestDatabase testDatabase)
+ {
+ _factory = new WebApplicationFactory().WithWebHostBuilder(builder =>
+ {
+ builder.ConfigureAppConfiguration((_, config) =>
+ {
+ var configValues = new Dictionary();
+ testDatabase.ModifyGlobalSettings(configValues);
+ config.AddInMemoryCollection(configValues);
+ });
+
+ builder.ConfigureServices(services =>
+ {
+ services.AddSingleton(Substitute.For());
+
+ // 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(options =>
+ {
+ options.Filters.Add(new IgnoreAntiforgeryTokenAttribute { Order = 1001 });
+ });
+
+ testDatabase.AddDatabase(services);
+ });
+ });
+ }
+
+ ///
+ /// Signs into the Admin Portal using the passwordless flow and returns a
+ /// client whose cookies carry an authenticated admin session.
+ ///
+ public async Task SignInAdminAsync()
+ {
+ const string Email = "admin@localhost";
+ var client = _factory.CreateClient();
+ var mailService = _factory.Services.GetRequiredService();
+
+ var loginResponse = await client.PostAsync("/login", new FormUrlEncodedContent(new Dictionary
+ {
+ { "Email", Email },
+ }));
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ public async Task InitializeBusinessUnitConversionAsync(
+ HttpClient adminSession, Guid organizationId, string providerAdminEmail)
+ {
+ var mailService = _factory.Services.GetRequiredService();
+
+ var response = await adminSession.PostAsync(
+ $"/organizations/billing/{organizationId}/business-unit",
+ new FormUrlEncodedContent(new Dictionary
+ {
+ { "ProviderAdminEmail", providerAdminEmail },
+ }));
+ 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();
+}
diff --git a/test/Billing.IntegrationTest/Billing.IntegrationTest.csproj b/test/Billing.IntegrationTest/Billing.IntegrationTest.csproj
new file mode 100644
index 000000000000..b072b6c7e49e
--- /dev/null
+++ b/test/Billing.IntegrationTest/Billing.IntegrationTest.csproj
@@ -0,0 +1,33 @@
+
+
+
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/Billing.IntegrationTest/BillingApplicationFactory.cs b/test/Billing.IntegrationTest/BillingApplicationFactory.cs
new file mode 100644
index 000000000000..5bf572171527
--- /dev/null
+++ b/test/Billing.IntegrationTest/BillingApplicationFactory.cs
@@ -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;
+
+///
+/// Application factory for the Bit.Billing webhook host. Holds an inner
+/// privately, shares the
+/// API host's database, and exposes a single intent method
+/// () that builds a valid signed Stripe
+/// event payload and posts it to the controller — tests interact only through
+/// that method.
+///
+public sealed class BillingApplicationFactory : IAsyncDisposable
+{
+ public const string WebhookKey = "test-webhook-key";
+ public const string WebhookSecret = "whsec_billing_integration_test_secret_value";
+
+ ///
+ /// Matches the API version the SDK is pinned to (see
+ /// ). The webhook controller
+ /// rejects events whose api_version doesn't match this value.
+ ///
+ public static string SupportedStripeApiVersion => Stripe.StripeConfiguration.ApiVersion;
+
+ private readonly WebApplicationFactory _factory;
+
+ public BillingApplicationFactory(ITestDatabase testDatabase)
+ {
+ _factory = new WebApplicationFactory().WithWebHostBuilder(builder =>
+ {
+ builder.UseEnvironment(Environments.Development);
+
+ builder.ConfigureAppConfiguration((_, config) =>
+ {
+ var configValues = new Dictionary
+ {
+ ["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();
+
+ testDatabase.AddDatabase(services);
+ });
+ });
+ }
+
+ ///
+ /// Builds a Stripe-signed event with the given and
+ /// , posts it to POST /stripe/webhook, 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.
+ ///
+ 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"),
+ };
+ 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);
+ }
+}
diff --git a/test/Billing.IntegrationTest/BillingFactAttribute.cs b/test/Billing.IntegrationTest/BillingFactAttribute.cs
new file mode 100644
index 000000000000..8a87e0c73aa0
--- /dev/null
+++ b/test/Billing.IntegrationTest/BillingFactAttribute.cs
@@ -0,0 +1,16 @@
+namespace Bit.Billing.IntegrationTest;
+
+///
+/// A fact that runs only when RUN_STRIPE_INTEGRATION_TESTS is set in the environment.
+/// These tests hit a real Stripe account, so they are opt-in to avoid running in CI.
+///
+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.";
+ }
+ }
+}
diff --git a/test/Billing.IntegrationTest/BusinessUnitConversionTests.cs b/test/Billing.IntegrationTest/BusinessUnitConversionTests.cs
new file mode 100644
index 000000000000..7a168fb322e6
--- /dev/null
+++ b/test/Billing.IntegrationTest/BusinessUnitConversionTests.cs
@@ -0,0 +1,15 @@
+using Bit.Test.Common.Helpers;
+
+namespace Bit.Billing.IntegrationTest;
+
+public class BusinessUnitConversionTests(StripeTestsFixture fixture) : IClassFixture
+{
+ [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);
+ }
+}
diff --git a/test/Billing.IntegrationTest/CancellingAndReinstatingSubscriptionTests.cs b/test/Billing.IntegrationTest/CancellingAndReinstatingSubscriptionTests.cs
new file mode 100644
index 000000000000..a64858ec9e96
--- /dev/null
+++ b/test/Billing.IntegrationTest/CancellingAndReinstatingSubscriptionTests.cs
@@ -0,0 +1,38 @@
+using System.Net.Http.Json;
+using Bit.Test.Common.Helpers;
+
+namespace Bit.Billing.IntegrationTest;
+
+public class CancellingAndReinstatingSubscriptionTests(StripeTestsFixture fixture) : IClassFixture
+{
+ [BillingFact]
+ public async Task CancelPremium_FetchesSubscriptionWithTestClockExpanded()
+ {
+ var client = await fixture.PreparePremiumUserAsync("cancel-premium@example.com");
+
+ // Drives SubscriberService.CancelSubscription -> GetSubscriptionOrThrow with
+ // Expand=["test_clock"].
+ var response = await client.PostAsJsonAsync(
+ "/accounts/cancel",
+ new { Reason = "user_test", Feedback = "integration test cancellation" });
+ await Assert.SuccessResponseAsync(response);
+ }
+
+ [BillingFact]
+ public async Task Reinstate_FetchesCanceledSubscriptionAndCreatesReplacement()
+ {
+ var client = await fixture.PreparePremiumUserAsync("reinstate-premium@example.com");
+
+ // Cancel first so reinstate has something to act on.
+ var cancelResponse = await client.PostAsJsonAsync(
+ "/accounts/cancel",
+ new { Reason = "user_test", Feedback = "" });
+ await Assert.SuccessResponseAsync(cancelResponse);
+
+ // Drives ReinstateSubscriptionCommand which fetches the canceled subscription
+ // with Expand=["discounts", "customer.discount"].
+ var reinstateResponse = await client.PostAsync(
+ "/account/billing/vnext/subscription/reinstate", content: null);
+ await Assert.SuccessResponseAsync(reinstateResponse);
+ }
+}
diff --git a/test/Billing.IntegrationTest/CreatingPremiumForExistingCustomerTests.cs b/test/Billing.IntegrationTest/CreatingPremiumForExistingCustomerTests.cs
new file mode 100644
index 000000000000..6ef783f4a793
--- /dev/null
+++ b/test/Billing.IntegrationTest/CreatingPremiumForExistingCustomerTests.cs
@@ -0,0 +1,36 @@
+using System.Net.Http.Json;
+using System.Text.Json.Nodes;
+using Bit.Test.Common.Helpers;
+
+namespace Bit.Billing.IntegrationTest;
+
+public class CreatingPremiumForExistingCustomerTests(StripeTestsFixture fixture) : IClassFixture
+{
+ [BillingFact]
+ public async Task RecreatingPremium_AfterCancellation_HitsExistingCustomerBranch()
+ {
+ const string email = "premium-resubscribe-existing-customer@example.com";
+ var client = await fixture.PreparePremiumUserAsync(email);
+
+ var profileResponse = await client.GetAsync("/accounts/profile");
+ await Assert.SuccessResponseAsync(profileResponse);
+ var userId = (await profileResponse.Content.ReadFromJsonAsync())!["id"]!.GetValue();
+
+ // Cancel the existing premium subscription immediately so the user keeps their
+ // GatewayCustomerId but has a Canceled (terminal) Stripe subscription.
+ await fixture.CancelUserSubscriptionImmediatelyAsync(userId);
+
+ // Re-purchase premium — CreatePremiumCloudHostedSubscriptionCommand sees an
+ // existing GatewayCustomerId plus a terminal subscription, so it takes the
+ // line-127 branch (updatePaymentMethod + GetCustomer with Expand=_expand).
+ var resubscribeResponse = await client.PostAsJsonAsync(
+ "/account/billing/vnext/subscription",
+ new
+ {
+ TokenizedPaymentMethod = new { Type = "card", Token = "pm_card_visa" },
+ BillingAddress = new { Country = "US", PostalCode = "43432" },
+ AdditionalStorageGb = 0,
+ });
+ await Assert.SuccessResponseAsync(resubscribeResponse);
+ }
+}
diff --git a/test/Billing.IntegrationTest/DiscountAudienceFilterTests.cs b/test/Billing.IntegrationTest/DiscountAudienceFilterTests.cs
new file mode 100644
index 000000000000..c65d40ee5323
--- /dev/null
+++ b/test/Billing.IntegrationTest/DiscountAudienceFilterTests.cs
@@ -0,0 +1,33 @@
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json.Nodes;
+using Bit.Test.Common.Helpers;
+
+namespace Bit.Billing.IntegrationTest;
+
+public class DiscountAudienceFilterTests(PersonalDiscountsFixture fixture) : IClassFixture
+{
+ [BillingFact]
+ public async Task GetDiscounts_ForUserWithCustomerButNoPreviousPremium_ListsStripeSubsWithItemsPriceExpand()
+ {
+ const string email = "discount-filter-no-prior-premium@example.com";
+ await fixture.SeedNoPreviousSubscriptionsDiscountAsync($"new_user_discount_{Guid.NewGuid():N}");
+
+ // Register a user and attach a bare Stripe customer (no subscription) to satisfy
+ // the filter's "user has GatewayCustomerId" branch — the listing then runs with
+ // Expand=["data.items.data.price"].
+ var (token, _) = await fixture.Api.LoginWithNewAccount(email);
+ var client = fixture.Api.CreateClient();
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
+
+ var profileResponse = await client.GetAsync("/accounts/profile");
+ await Assert.SuccessResponseAsync(profileResponse);
+ var profile = (await profileResponse.Content.ReadFromJsonAsync())!;
+ var userId = profile["id"]!.GetValue();
+
+ await fixture.CreateOrphanedStripeCustomerForUserAsync(userId, email);
+
+ var response = await client.GetAsync("/account/billing/vnext/discounts");
+ await Assert.SuccessResponseAsync(response);
+ }
+}
diff --git a/test/Billing.IntegrationTest/IdentityApplicationFactoryExtensions.cs b/test/Billing.IntegrationTest/IdentityApplicationFactoryExtensions.cs
new file mode 100644
index 000000000000..06b44a95656c
--- /dev/null
+++ b/test/Billing.IntegrationTest/IdentityApplicationFactoryExtensions.cs
@@ -0,0 +1,36 @@
+using System.Net.Http.Json;
+using System.Text.Json.Nodes;
+using Bit.IntegrationTestCommon.Factories;
+using Bit.Test.Common.Helpers;
+
+namespace Bit.Billing.IntegrationTest;
+
+///
+/// Test-host extensions that own the bits of identity flow our suite needs,
+/// so this project doesn't reach back into the legacy IdentityApplicationFactory
+/// helpers.
+///
+public static class IdentityApplicationFactoryExtensions
+{
+ ///
+ /// Exchanges a refresh token for a fresh access + refresh token pair via
+ /// the Identity host's /connect/token endpoint.
+ ///
+ public static async Task<(string Token, string RefreshToken)> TokenFromRefreshAsync(
+ this IdentityApplicationFactory identity,
+ string refreshToken)
+ {
+ using var client = identity.CreateDefaultClient();
+ var response = await client.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary
+ {
+ { "client_id", "web" },
+ { "grant_type", "refresh_token" },
+ { "refresh_token", refreshToken },
+ }));
+
+ await Assert.SuccessResponseAsync(response);
+
+ var tokens = (await response.Content.ReadFromJsonAsync())!;
+ return (tokens["access_token"]!.GetValue(), tokens["refresh_token"]!.GetValue());
+ }
+}
diff --git a/test/Billing.IntegrationTest/LegacyBillingFlagsFixture.cs b/test/Billing.IntegrationTest/LegacyBillingFlagsFixture.cs
new file mode 100644
index 000000000000..574431ecc339
--- /dev/null
+++ b/test/Billing.IntegrationTest/LegacyBillingFlagsFixture.cs
@@ -0,0 +1,30 @@
+using Bit.Api.IntegrationTest.Factories;
+using Bit.Core;
+
+namespace Bit.Billing.IntegrationTest;
+
+///
+/// Variant of that turns off the feature flags
+/// gating the vnext billing flow, so tests can drive the legacy
+/// and
+/// UpdateSecretsManagerSubscriptionCommand Stripe paths.
+///
+public sealed class LegacyBillingFlagsFixture : StripeTestsFixture
+{
+ protected override ApiApplicationFactory CreateApi()
+ {
+ var api = new ApiApplicationFactory
+ {
+ StripeEnabled = true,
+ };
+
+ api.UpdateConfiguration(
+ $"globalSettings:launchDarkly:flagValues:{FeatureFlagKeys.PM32581_UseUpdateOrganizationSubscriptionCommand}",
+ "false");
+ api.UpdateConfiguration(
+ $"globalSettings:launchDarkly:flagValues:{FeatureFlagKeys.PM37597_AlwaysEnableStripeAutomaticTax}",
+ "false");
+
+ return api;
+ }
+}
diff --git a/test/Billing.IntegrationTest/LegacyBillingFlowsTests.cs b/test/Billing.IntegrationTest/LegacyBillingFlowsTests.cs
new file mode 100644
index 000000000000..2d5082b67cd2
--- /dev/null
+++ b/test/Billing.IntegrationTest/LegacyBillingFlowsTests.cs
@@ -0,0 +1,86 @@
+using System.Net.Http.Json;
+using Bit.Core.Billing.Enums;
+using Bit.Test.Common.Helpers;
+
+namespace Bit.Billing.IntegrationTest;
+
+///
+/// Tests that exercise legacy billing code paths gated by PM32581 / PM37597.
+/// They use so the flags resolve to
+/// false during the host's lifetime.
+///
+public class LegacyBillingFlowsTests(LegacyBillingFlagsFixture fixture) : IClassFixture
+{
+ [BillingFact]
+ public async Task SecretsManagerSubscriptionUpdate_RoutesThroughLegacyCommand()
+ {
+ var (client, _, organizationId, _) =
+ await fixture.PrepareOrganizationOwnerAsync("legacy-sm-subscription@example.com");
+
+ // Subscribe to Secrets Manager first so the sm-subscription endpoint has line items.
+ var subscribeResponse = await client.PostAsJsonAsync(
+ $"/organizations/{organizationId}/subscribe-secrets-manager",
+ new { AdditionalSmSeats = 2, AdditionalServiceAccounts = 0 });
+ await Assert.SuccessResponseAsync(subscribeResponse);
+
+ // With PM32581 off, UpdateSecretsManagerSubscriptionCommand runs the legacy
+ // Stripe path that fetches the subscription with Expand=["customer", "test_clock"].
+ var adjustResponse = await client.PostAsJsonAsync(
+ $"/organizations/{organizationId}/sm-subscription",
+ new { SeatAdjustment = 3, ServiceAccountAdjustment = 0 });
+ await Assert.SuccessResponseAsync(adjustResponse);
+ }
+
+ [BillingFact]
+ public async Task PlanUpgrade_FromFamilies_ReusesCustomerViaFinalize()
+ {
+ // Families org → existing Stripe customer. With PM32581 off the legacy
+ // UpgradeOrganizationPlanCommand path runs and calls OrganizationBillingService
+ // .Finalize → GetCustomerWhileEnsuringCorrectTaxExemptionAsync.
+ var (client, _, organizationId, _) =
+ await fixture.PrepareOrganizationOwnerAsync(
+ "legacy-plan-upgrade@example.com", PlanType.FamiliesAnnually);
+
+ var response = await client.PostAsJsonAsync(
+ $"/organizations/{organizationId}/upgrade",
+ new
+ {
+ PlanType = PlanType.EnterpriseAnnually,
+ AdditionalSeats = 5,
+ UseSecretsManager = false,
+ BillingAddressCountry = "US",
+ BillingAddressPostalCode = "43432",
+ });
+ await Assert.SuccessResponseAsync(response);
+ }
+
+ [BillingFact]
+ public async Task PlanUpgrade_FromFamilies_ReconcilesTaxExemptionForNonUsAddress()
+ {
+ // Drive the legacy tax-exemption reconciliation branch: PM37597 must be off AND the
+ // new plan must be Teams/Enterprise AND customer.TaxExempt must differ from the
+ // determined value (forced by using a non-US billing address where Bitwarden has no
+ // tax registration, which flips TaxExempt to Reverse).
+ var (client, _, organizationId, _) =
+ await fixture.PrepareOrganizationOwnerAsync(
+ "legacy-tax-reconcile@example.com", PlanType.FamiliesAnnually);
+
+ // Move the Families org's address out of US so the determined exemption changes.
+ var addressResponse = await client.PutAsJsonAsync(
+ $"/organizations/{organizationId}/billing/vnext/address",
+ new { Country = "BR", PostalCode = "01310-100" });
+ await Assert.SuccessResponseAsync(addressResponse);
+
+ var upgradeResponse = await client.PostAsJsonAsync(
+ $"/organizations/{organizationId}/upgrade",
+ new
+ {
+ PlanType = PlanType.EnterpriseAnnually,
+ AdditionalSeats = 5,
+ UseSecretsManager = false,
+ BillingAddressCountry = "BR",
+ BillingAddressPostalCode = "01310-100",
+ });
+ await Assert.SuccessResponseAsync(upgradeResponse);
+ }
+}
diff --git a/test/Billing.IntegrationTest/MigrationCohortTests.cs b/test/Billing.IntegrationTest/MigrationCohortTests.cs
new file mode 100644
index 000000000000..730da7c8bccc
--- /dev/null
+++ b/test/Billing.IntegrationTest/MigrationCohortTests.cs
@@ -0,0 +1,52 @@
+using Bit.Core.Billing.Organizations.PlanMigration.Enums;
+using Bit.Test.Common.Helpers;
+
+namespace Bit.Billing.IntegrationTest;
+
+public class MigrationCohortTests(StripeTestsFixture fixture) : IClassFixture
+{
+ [BillingFact]
+ public async Task ChurnMitigationOffer_FetchesSubscriptionWithCustomerTestClockDiscountsExpand()
+ {
+ var (client, _, organizationId, _) =
+ await fixture.PrepareOrganizationOwnerAsync("churn-offer@example.com");
+ await fixture.SeedChurnOnlyCohortAsync(organizationId, $"churn_offer_{Guid.NewGuid():N}");
+
+ // Drives GetChurnMitigationOfferQuery -> TryGetSubscriptionAsync with
+ // Expand=["customer", "test_clock", "discounts.coupon"].
+ var response = await client.GetAsync(
+ $"/organizations/{organizationId}/billing/vnext/churn-mitigation-offer");
+ await Assert.SuccessResponseAsync(response);
+ }
+
+ [BillingFact]
+ public async Task RedeemChurnMitigationOffer_FetchesSubscriptionWithSameExpand()
+ {
+ var (client, _, organizationId, _) =
+ await fixture.PrepareOrganizationOwnerAsync("churn-redeem@example.com");
+ await fixture.SeedChurnOnlyCohortAsync(organizationId, $"churn_redeem_{Guid.NewGuid():N}");
+
+ // Drives RedeemChurnMitigationOfferCommand which fetches the subscription with
+ // the same Expand.
+ var response = await client.PostAsync(
+ $"/organizations/{organizationId}/billing/vnext/churn-mitigation-offer/redeem",
+ content: null);
+ await Assert.SuccessResponseAsync(response);
+ }
+
+ [BillingFact]
+ public async Task SubscriptionRead_WithMigrationScheduleAndCohort_ReadsSchedulePhase2()
+ {
+ // Premium-user subscription endpoint that reads the schedule's Phase 2 coupons —
+ // exercises GetBitwardenSubscriptionQuery.GetSchedulePhase2CouponsAsync with
+ // Expand=["phases.discounts.coupon.applies_to"].
+ // We seed it against an organization, then read /organizations/{id}/subscription
+ // which exercises the legacy StripePaymentService schedule Expand.
+ var (client, _, organizationId, _) =
+ await fixture.PrepareOrganizationOwnerAsync("schedule-phase2-org@example.com");
+ await fixture.SeedMigrationCohortWithScheduleAsync(organizationId, MigrationPathId.Enterprise2020AnnualToCurrent);
+
+ var response = await client.GetAsync($"/organizations/{organizationId}/subscription");
+ await Assert.SuccessResponseAsync(response);
+ }
+}
diff --git a/test/Billing.IntegrationTest/PersonalDiscountsFixture.cs b/test/Billing.IntegrationTest/PersonalDiscountsFixture.cs
new file mode 100644
index 000000000000..739d78fa8c42
--- /dev/null
+++ b/test/Billing.IntegrationTest/PersonalDiscountsFixture.cs
@@ -0,0 +1,26 @@
+using Bit.Api.IntegrationTest.Factories;
+using Bit.Core;
+
+namespace Bit.Billing.IntegrationTest;
+
+///
+/// Variant of that enables the personal discounts
+/// feature flag so the GET /account/billing/vnext/discounts endpoint is
+/// reachable and the discount-audience filter pipeline runs.
+///
+public sealed class PersonalDiscountsFixture : StripeTestsFixture
+{
+ protected override ApiApplicationFactory CreateApi()
+ {
+ var api = new ApiApplicationFactory
+ {
+ StripeEnabled = true,
+ };
+
+ api.UpdateConfiguration(
+ $"globalSettings:launchDarkly:flagValues:{FeatureFlagKeys.PM29108_EnablePersonalDiscounts}",
+ "true");
+
+ return api;
+ }
+}
diff --git a/test/Billing.IntegrationTest/PreExistingStateTests.cs b/test/Billing.IntegrationTest/PreExistingStateTests.cs
new file mode 100644
index 000000000000..89cac5bf4f51
--- /dev/null
+++ b/test/Billing.IntegrationTest/PreExistingStateTests.cs
@@ -0,0 +1,34 @@
+using Bit.Test.Common.Helpers;
+
+namespace Bit.Billing.IntegrationTest;
+
+public class PreExistingStateTests(StripeTestsFixture fixture) : IClassFixture
+{
+ [BillingFact]
+ public async Task RemoveOrganizationFromProvider_FetchesCustomerWithInvoiceSettingsAndSources()
+ {
+ // PrepareProviderAdminAsync converts an org to a provider-managed org, so removing
+ // *any* org from that provider lands on the managed organization we just created.
+ var (_, providerId) = await fixture.PrepareProviderAdminAsync(
+ "remove-org-from-provider@example.com");
+
+ // Drives RemoveOrganizationFromProviderCommand -> ISubscriberService.RemovePaymentSource
+ // on the organization, which calls GetCustomerOrThrow with
+ // Expand=["invoice_settings.default_payment_method", "sources"].
+ await fixture.RemoveAnyOrganizationFromProviderAsync(providerId);
+ }
+
+ [BillingFact]
+ public async Task ProviderPaymentSource_WhenCustomerHasNoDefaultPM_ListsSetupIntentsWithPaymentMethodExpand()
+ {
+ var (client, providerId) = await fixture.PrepareProviderAdminAsync(
+ "provider-no-default-pm@example.com");
+ var customerId = await fixture.GetProviderGatewayCustomerIdAsync(providerId);
+ await fixture.DetachDefaultPaymentMethodAsync(customerId);
+
+ // Drives subscriberService.GetPaymentSource -> GetPaymentSourceAsync -> the
+ // setup-intents listing with Expand=["data.payment_method"].
+ var response = await client.GetAsync($"/providers/{providerId}/billing/subscription");
+ await Assert.SuccessResponseAsync(response);
+ }
+}
diff --git a/test/Billing.IntegrationTest/README.md b/test/Billing.IntegrationTest/README.md
new file mode 100644
index 000000000000..37659bcdc12f
--- /dev/null
+++ b/test/Billing.IntegrationTest/README.md
@@ -0,0 +1,51 @@
+# Billing.IntegrationTest
+
+These tests exercise Bitwarden's billing surface against a **real Stripe test account**. They exist to catch one specific class of bug that unit tests cannot see: a mismatch between the `Expand = [...]` paths a query asks Stripe for and the fields the C# code subsequently reads back.
+
+## Why these tests exist
+
+Bitwarden's billing code routinely asks the Stripe SDK to inline related objects on a single request:
+
+```csharp
+var subscription = await subscriberService.GetSubscription(
+ organization,
+ new SubscriptionGetOptions { Expand = ["customer.tax_ids", "latest_invoice", "test_clock"] });
+
+// Later, the same code path reads:
+var taxIds = subscription.Customer.TaxIds; // requires "customer.tax_ids"
+var status = subscription.LatestInvoice.Status; // requires "latest_invoice"
+```
+
+`Expand` is a Stripe API convention: paths not listed in `Expand` come back as **id-only stubs**, not full objects. If a developer reads `subscription.LatestInvoice.Status` but forgets `"latest_invoice"` in `Expand`, Stripe returns the invoice id only — `LatestInvoice` is a stub with `Status == null` — and the code path silently produces a wrong answer (or a `NullReferenceException` at runtime).
+
+Unit tests can't catch this:
+- Mocked Stripe services return whatever object graph the test sets up, regardless of the `Expand` list the production code passed in.
+- Static analysis can't tell from the call site which property accesses require which `Expand` paths.
+
+This project closes the gap. Every scenario walks a billing flow end-to-end against real Stripe and asserts the response carries the fields the code expects. A missing `Expand` entry surfaces as a real test failure.
+
+## What belongs here
+
+Any production code path that:
+
+1. Constructs a Stripe SDK options object with `Expand = [...]`, **and**
+2. Reads at least one of the expanded fields off the returned Stripe object.
+
+When you add or modify such a code path, add (or update) a scenario here that drives it through HTTP and asserts on the expanded data. Pure CRUD with no expand paths, or logic with no Stripe call at all, belongs in a unit test instead.
+
+## Running
+
+Tests gate on the `RUN_STRIPE_INTEGRATION_TESTS` environment variable (see [`BillingFactAttribute`](BillingFactAttribute.cs)). They're skipped by default so CI never spends time on real Stripe API calls.
+
+```bash
+export RUN_STRIPE_INTEGRATION_TESTS=1
+# Stripe API key sourced from user-secrets in src/Identity:
+# globalSettings:stripe:apiKey = sk_test_...
+dotnet test test/Billing.IntegrationTest
+```
+
+The Stripe key must be a **test-mode** key (`sk_test_...`). The tests POST organizations using the canonical Stripe test card token `pm_card_visa` and exercise real network calls; they will fail loudly against a live-mode key (and would be unsafe to run there).
+
+## Conventions
+
+Follow the patterns in [`test/INTEGRATION_TEST.md`](../INTEGRATION_TEST.md).
diff --git a/test/Billing.IntegrationTest/RetrievingOrganizationBillingTests.cs b/test/Billing.IntegrationTest/RetrievingOrganizationBillingTests.cs
new file mode 100644
index 000000000000..f650750d3d29
--- /dev/null
+++ b/test/Billing.IntegrationTest/RetrievingOrganizationBillingTests.cs
@@ -0,0 +1,62 @@
+using System.Net.Http.Json;
+using System.Text.Json.Nodes;
+using Bit.Test.Common.Helpers;
+
+namespace Bit.Billing.IntegrationTest;
+
+public class RetrievingOrganizationBillingTests(StripeTestsFixture fixture) : IClassFixture
+{
+ [BillingFact]
+ public async Task PaymentMethod_ReturnsTheStripeVisaTestCard()
+ {
+ var (client, _, organizationId, _) =
+ await fixture.PrepareOrganizationOwnerAsync("payment-method@example.com");
+
+ var response = await client.GetAsync($"/organizations/{organizationId}/billing/vnext/payment-method");
+ await Assert.SuccessResponseAsync(response);
+
+ var paymentMethod = (await response.Content.ReadFromJsonAsync())!;
+ Assert.Equal("card", paymentMethod["type"]!.GetValue());
+ Assert.Equal("visa", paymentMethod["brand"]!.GetValue());
+ }
+
+ [BillingFact]
+ public async Task BillingAddress_ReturnsTheAddressCapturedAtSignup()
+ {
+ var (client, _, organizationId, _) =
+ await fixture.PrepareOrganizationOwnerAsync("billing-address@example.com");
+
+ var response = await client.GetAsync($"/organizations/{organizationId}/billing/vnext/address");
+ await Assert.SuccessResponseAsync(response);
+
+ var billingAddress = (await response.Content.ReadFromJsonAsync())!;
+ Assert.Equal("US", billingAddress["country"]!.GetValue());
+ Assert.Equal("43432", billingAddress["postalCode"]!.GetValue());
+ }
+
+ [BillingFact]
+ public async Task Metadata_ReportsOrganizationIsNotOnSecretsManagerStandalone()
+ {
+ var (client, _, organizationId, _) =
+ await fixture.PrepareOrganizationOwnerAsync("metadata@example.com");
+
+ var response = await client.GetAsync($"/organizations/{organizationId}/billing/vnext/metadata");
+ await Assert.SuccessResponseAsync(response);
+
+ var metadata = (await response.Content.ReadFromJsonAsync())!;
+ Assert.False(metadata["isOnSecretsManagerStandalone"]!.GetValue());
+ }
+
+ [BillingFact]
+ public async Task Warnings_ReturnsAWarningsObject()
+ {
+ var (client, _, organizationId, _) =
+ await fixture.PrepareOrganizationOwnerAsync("warnings@example.com");
+
+ var response = await client.GetAsync($"/organizations/{organizationId}/billing/vnext/warnings");
+ await Assert.SuccessResponseAsync(response);
+
+ var warnings = await response.Content.ReadFromJsonAsync();
+ Assert.NotNull(warnings);
+ }
+}
diff --git a/test/Billing.IntegrationTest/RetrievingPremiumSubscriptionTests.cs b/test/Billing.IntegrationTest/RetrievingPremiumSubscriptionTests.cs
new file mode 100644
index 000000000000..e2ea3c8e9364
--- /dev/null
+++ b/test/Billing.IntegrationTest/RetrievingPremiumSubscriptionTests.cs
@@ -0,0 +1,40 @@
+using System.Net.Http.Json;
+using System.Text.Json.Nodes;
+using Bit.Test.Common.Helpers;
+
+namespace Bit.Billing.IntegrationTest;
+
+public class RetrievingPremiumSubscriptionTests(StripeTestsFixture fixture) : IClassFixture
+{
+ [BillingFact]
+ public async Task Subscription_ForActivePremiumUser_ReturnsTheCanonicalCartAndStorage()
+ {
+ var client = await fixture.PreparePremiumUserAsync("premium-subscription@example.com");
+
+ // Drives GetBitwardenSubscriptionQuery.FetchSubscriptionAsync, which lists
+ // schedules with Expand=phases.discounts.coupon.applies_to and reads the
+ // subscription's items / coupons. Cart shape requires both expand paths
+ // to be honored or the response would shed line items / discounts.
+ var response = await client.GetAsync("/account/billing/vnext/subscription");
+ await Assert.SuccessResponseAsync(response);
+
+ var subscription = (await response.Content.ReadFromJsonAsync())!;
+ Assert.Equal("active", subscription["status"]!.GetValue());
+ Assert.NotNull(subscription["cart"]);
+ Assert.NotNull(subscription["storage"]);
+ Assert.NotNull(subscription["nextCharge"]);
+ }
+
+ [BillingFact]
+ public async Task PaymentMethod_ForPremiumUser_ReturnsTheStripeVisaTestCard()
+ {
+ var client = await fixture.PreparePremiumUserAsync("premium-payment-method@example.com");
+
+ var response = await client.GetAsync("/account/billing/vnext/payment-method");
+ await Assert.SuccessResponseAsync(response);
+
+ var paymentMethod = (await response.Content.ReadFromJsonAsync())!;
+ Assert.Equal("card", paymentMethod["type"]!.GetValue());
+ Assert.Equal("visa", paymentMethod["brand"]!.GetValue());
+ }
+}
diff --git a/test/Billing.IntegrationTest/RetrievingProviderBillingTests.cs b/test/Billing.IntegrationTest/RetrievingProviderBillingTests.cs
new file mode 100644
index 000000000000..d4cc4cdc221d
--- /dev/null
+++ b/test/Billing.IntegrationTest/RetrievingProviderBillingTests.cs
@@ -0,0 +1,53 @@
+using System.Net.Http.Json;
+using System.Text.Json.Nodes;
+using Bit.Test.Common.Helpers;
+
+namespace Bit.Billing.IntegrationTest;
+
+public class RetrievingProviderBillingTests(StripeTestsFixture fixture) : IClassFixture
+{
+ [BillingFact]
+ public async Task Subscription_ReturnsTheCanonicalProviderSubscriptionFields()
+ {
+ var (client, providerId) = await fixture.PrepareProviderAdminAsync("provider-subscription@example.com");
+
+ var response = await client.GetAsync($"/providers/{providerId}/billing/subscription");
+ await Assert.SuccessResponseAsync(response);
+
+ // Drives ProviderBillingController.GetSubscriptionAsync, which reads
+ // subscription.Customer (customer.tax_ids expand), subscription.Discounts
+ // (discounts expand) and uses test_clock to compute the period end.
+ var subscription = (await response.Content.ReadFromJsonAsync())!;
+ Assert.Equal("trialing", subscription["status"]!.GetValue());
+ Assert.NotNull(subscription["currentPeriodEndDate"]);
+ Assert.NotNull(subscription["taxInformation"]);
+ Assert.NotNull(subscription["plans"]);
+ Assert.NotNull(subscription["paymentSource"]);
+ }
+
+ [BillingFact]
+ public async Task BillingAddress_ReturnsTheAddressCapturedAtSignup()
+ {
+ var (client, providerId) = await fixture.PrepareProviderAdminAsync("provider-address@example.com");
+
+ var response = await client.GetAsync($"/providers/{providerId}/billing/vnext/address");
+ await Assert.SuccessResponseAsync(response);
+
+ var billingAddress = (await response.Content.ReadFromJsonAsync())!;
+ Assert.Equal("US", billingAddress["country"]!.GetValue());
+ Assert.Equal("43432", billingAddress["postalCode"]!.GetValue());
+ }
+
+ [BillingFact]
+ public async Task PaymentMethod_ReturnsTheStripeVisaTestCard()
+ {
+ var (client, providerId) = await fixture.PrepareProviderAdminAsync("provider-payment-method@example.com");
+
+ var response = await client.GetAsync($"/providers/{providerId}/billing/vnext/payment-method");
+ await Assert.SuccessResponseAsync(response);
+
+ var paymentMethod = (await response.Content.ReadFromJsonAsync())!;
+ Assert.Equal("card", paymentMethod["type"]!.GetValue());
+ Assert.Equal("visa", paymentMethod["brand"]!.GetValue());
+ }
+}
diff --git a/test/Billing.IntegrationTest/StripeTestsFixture.cs b/test/Billing.IntegrationTest/StripeTestsFixture.cs
new file mode 100644
index 000000000000..fbb254643eb7
--- /dev/null
+++ b/test/Billing.IntegrationTest/StripeTestsFixture.cs
@@ -0,0 +1,573 @@
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json.Nodes;
+using Bit.Api.IntegrationTest.Factories;
+using Bit.Core.AdminConsole.Providers.Interfaces;
+using Bit.Core.AdminConsole.Repositories;
+using Bit.Core.Billing.Constants;
+using Bit.Core.Billing.Enums;
+using Bit.Core.Billing.Organizations.PlanMigration.Entities;
+using Bit.Core.Billing.Organizations.PlanMigration.Enums;
+using Bit.Core.Billing.Organizations.PlanMigration.Repositories;
+using Bit.Core.Billing.Services;
+using Bit.Core.Billing.Subscriptions.Entities;
+using Bit.Core.Billing.Subscriptions.Repositories;
+using Bit.Core.Enums;
+using Bit.Core.Repositories;
+using Bit.Core.Settings;
+using Bit.Test.Common.Helpers;
+using Microsoft.Extensions.DependencyInjection;
+using Stripe;
+
+namespace Bit.Billing.IntegrationTest;
+
+public class StripeTestsFixture : IAsyncDisposable
+{
+ public ApiApplicationFactory Api { get; }
+ public AdminApplicationFactory Admin { get; }
+
+ public StripeTestsFixture()
+ {
+ Api = CreateApi();
+ Admin = new AdminApplicationFactory(Api.TestDatabase);
+ }
+
+ ///
+ /// Hook for subclasses (e.g. flag-override fixtures) to apply additional
+ /// configuration to the API factory before the base fixture wires the
+ /// Admin host against its TestDatabase.
+ ///
+ protected virtual ApiApplicationFactory CreateApi()
+ {
+ return new ApiApplicationFactory
+ {
+ StripeEnabled = true,
+ };
+ }
+
+ ///
+ /// Builds a fresh from the API host's resolved
+ /// . Avoids relying on a DI registration for
+ /// , which the production code does not provide.
+ ///
+ private StripeClient CreateStripeClient()
+ {
+ var settings = Api.Services.GetRequiredService().Stripe;
+ return new(settings.ApiKey, httpClient: new SystemNetHttpClient(maxNetworkRetries: settings.MaxNetworkRetries));
+ }
+
+ ///
+ /// Registers a new user, logs them in, creates an organization on the
+ /// requested plan billed via the Stripe test Visa card, and refreshes the
+ /// access token so it carries the new organization-owner claims. Returns
+ /// the authenticated client, the user and organization ids, and the latest
+ /// refresh token for tests that need to re-issue (e.g. picking up further
+ /// claims after a provider is created). Defaults to Enterprise (Annually)
+ /// for the typical business-tier scenario.
+ ///
+ public async Task<(HttpClient Client, Guid UserId, Guid OrganizationId, string RefreshToken)>
+ PrepareOrganizationOwnerAsync(string email, PlanType planType = PlanType.EnterpriseAnnually)
+ {
+ var (token, refreshToken) = await Api.LoginWithNewAccount(email);
+ var client = Api.CreateClient();
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
+
+ var profileResponse = await client.GetAsync("/accounts/profile");
+ await Assert.SuccessResponseAsync(profileResponse);
+ var profile = (await profileResponse.Content.ReadFromJsonAsync())!;
+ var userId = profile["id"]!.GetValue();
+
+ // Families is a fixed-seat plan; AdditionalSeats is rejected for it.
+ // Business plans (Teams, Enterprise) take seats explicitly.
+ var additionalSeats = planType is PlanType.FamiliesAnnually ? 0 : 10;
+
+ var createResponse = await client.PostAsJsonAsync("/organizations", new
+ {
+ Name = "Test Organization",
+ BusinessName = "Test Business Name",
+ BillingEmail = email,
+ PlanType = planType,
+ Key = "test_key",
+ Keys = new
+ {
+ PublicKey = "test_public_key",
+ EncryptedPrivateKey = "test_encrypted_private_key",
+ },
+ PaymentToken = "pm_card_visa",
+ PaymentMethodType = PaymentMethodType.Card,
+ BillingAddressCountry = "US",
+ BillingAddressPostalCode = "43432",
+ AdditionalSeats = additionalSeats,
+ });
+ await Assert.SuccessResponseAsync(createResponse);
+
+ var createdOrganization = (await createResponse.Content.ReadFromJsonAsync())!;
+ var organizationId = createdOrganization["id"]!.GetValue();
+
+ // Refresh so the bearer token carries the new organization-owner claims.
+ (token, refreshToken) = await Api.Identity.TokenFromRefreshAsync(refreshToken);
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
+
+ return (client, userId, organizationId, refreshToken);
+ }
+
+ ///
+ /// Registers a new user, creates an Enterprise organization billed via the
+ /// Stripe test Visa card, runs an Admin-driven business-unit conversion to
+ /// turn it into a Bitwarden provider, and refreshes the access token so it
+ /// carries the new provider-admin claims. Returns the authenticated client
+ /// and the new provider id.
+ ///
+ public async Task<(HttpClient Client, Guid ProviderId)> PrepareProviderAdminAsync(string email)
+ {
+ var (client, userId, organizationId, refreshToken) = await PrepareOrganizationOwnerAsync(email);
+
+ var adminSession = await Admin.SignInAdminAsync();
+ var invitationToken = await Admin.InitializeBusinessUnitConversionAsync(adminSession, organizationId, email);
+
+ // Refresh to pick up organization-admin claims set during conversion init.
+ var (token, providerRefreshToken) = await Api.Identity.TokenFromRefreshAsync(refreshToken);
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
+
+ var setupResponse = await client.PostAsJsonAsync(
+ $"/organizations/{organizationId}/billing/setup-business-unit",
+ new
+ {
+ UserId = userId,
+ Token = invitationToken,
+ ProviderKey = "provider_key",
+ OrganizationKey = "organization_key",
+ });
+ await Assert.SuccessResponseAsync(setupResponse);
+
+ var providerId = (await setupResponse.Content.ReadFromJsonAsync())!.GetValue();
+
+ // Refresh again to pick up the new provider-admin claims.
+ (token, _) = await Api.Identity.TokenFromRefreshAsync(providerRefreshToken);
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
+
+ return (client, providerId);
+ }
+
+ ///
+ /// Creates a verified-instantly Stripe SetupIntent backed by a us_bank_account
+ /// payment method (Stripe test routing + account numbers) and returns the
+ /// payment method id. Tests pass this id to PUT /payment-method, where
+ /// UpdatePaymentMethodCommand.AddBankAccountAsync lists setup intents with
+ /// Expand=data.payment_method to attach it to the subscriber's customer.
+ ///
+ public async Task CreateConfirmedBankAccountSetupIntentAsync(string email)
+ {
+ var stripeClient = CreateStripeClient();
+
+ var paymentMethod = await stripeClient.V1.PaymentMethods.CreateAsync(new PaymentMethodCreateOptions
+ {
+ Type = "us_bank_account",
+ UsBankAccount = new PaymentMethodUsBankAccountOptions
+ {
+ RoutingNumber = "110000000",
+ AccountNumber = "000111111116",
+ AccountHolderType = "individual",
+ AccountType = "checking",
+ },
+ BillingDetails = new PaymentMethodBillingDetailsOptions
+ {
+ Name = "Test User",
+ Email = email,
+ },
+ });
+
+ await stripeClient.V1.SetupIntents.CreateAsync(new SetupIntentCreateOptions
+ {
+ PaymentMethod = paymentMethod.Id,
+ PaymentMethodTypes = ["us_bank_account"],
+ Usage = "off_session",
+ Confirm = true,
+ MandateData = new SetupIntentMandateDataOptions
+ {
+ CustomerAcceptance = new SetupIntentMandateDataCustomerAcceptanceOptions
+ {
+ Type = "online",
+ Online = new SetupIntentMandateDataCustomerAcceptanceOnlineOptions
+ {
+ IpAddress = "127.0.0.1",
+ UserAgent = "Bit.Billing.IntegrationTest",
+ },
+ },
+ },
+ });
+
+ return paymentMethod.Id;
+ }
+
+ ///
+ /// Registers a new user, purchases a Premium cloud-hosted subscription
+ /// billed via the Stripe test Visa card, and refreshes the access token so
+ /// it carries the new premium claim. Returns the authenticated client.
+ ///
+ public async Task PreparePremiumUserAsync(string email)
+ {
+ var (token, refreshToken) = await Api.LoginWithNewAccount(email);
+ var client = Api.CreateClient();
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
+
+ var subscriptionResponse = await client.PostAsJsonAsync("/account/billing/vnext/subscription", new
+ {
+ TokenizedPaymentMethod = new { Type = "card", Token = "pm_card_visa" },
+ BillingAddress = new { Country = "US", PostalCode = "43432" },
+ AdditionalStorageGb = 0,
+ });
+ await Assert.SuccessResponseAsync(subscriptionResponse);
+
+ // Refresh so the bearer token carries the new premium claim.
+ (token, _) = await Api.Identity.TokenFromRefreshAsync(refreshToken);
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
+
+ return client;
+ }
+
+ ///
+ /// Looks up the persisted Stripe customer id for an organization. Used by
+ /// webhook tests to craft event payloads referencing real subscribers.
+ ///
+ public async Task GetOrganizationGatewayCustomerIdAsync(Guid organizationId)
+ {
+ using var scope = Api.Services.CreateScope();
+ var organization = await scope.ServiceProvider.GetRequiredService()
+ .GetByIdAsync(organizationId);
+ return organization!.GatewayCustomerId!;
+ }
+
+ ///
+ /// Looks up the persisted Stripe subscription id for an organization.
+ ///
+ public async Task GetOrganizationGatewaySubscriptionIdAsync(Guid organizationId)
+ {
+ using var scope = Api.Services.CreateScope();
+ var organization = await scope.ServiceProvider.GetRequiredService()
+ .GetByIdAsync(organizationId);
+ return organization!.GatewaySubscriptionId!;
+ }
+
+ ///
+ /// Looks up the persisted Stripe customer id for a provider.
+ ///
+ public async Task GetProviderGatewayCustomerIdAsync(Guid providerId)
+ {
+ using var scope = Api.Services.CreateScope();
+ var provider = await scope.ServiceProvider.GetRequiredService()
+ .GetByIdAsync(providerId);
+ return provider!.GatewayCustomerId!;
+ }
+
+ ///
+ /// Looks up the persisted Stripe customer id for a user (premium subscriber).
+ ///
+ public async Task GetUserGatewayCustomerIdAsync(Guid userId)
+ {
+ using var scope = Api.Services.CreateScope();
+ var user = await scope.ServiceProvider.GetRequiredService()
+ .GetByIdAsync(userId);
+ return user!.GatewayCustomerId!;
+ }
+
+ ///
+ /// Looks up the persisted Stripe subscription id for a user (premium subscriber).
+ ///
+ public async Task GetUserGatewaySubscriptionIdAsync(Guid userId)
+ {
+ using var scope = Api.Services.CreateScope();
+ var user = await scope.ServiceProvider.GetRequiredService()
+ .GetByIdAsync(userId);
+ return user!.GatewaySubscriptionId!;
+ }
+
+ ///
+ /// Creates a real test-mode charge against the organization's customer and
+ /// returns its id, used by webhook tests that simulate charge.succeeded.
+ /// Fresh signups are on a trial and have no charges yet, so we make one.
+ ///
+ public async Task CreateChargeForOrganizationAsync(Guid organizationId)
+ {
+ var customerId = await GetOrganizationGatewayCustomerIdAsync(organizationId);
+ var stripeClient = CreateStripeClient();
+ var paymentIntent = await stripeClient.V1.PaymentIntents.CreateAsync(new PaymentIntentCreateOptions
+ {
+ Customer = customerId,
+ Amount = 100,
+ Currency = "usd",
+ PaymentMethod = "pm_card_visa",
+ Confirm = true,
+ OffSession = true,
+ AutomaticPaymentMethods = new PaymentIntentAutomaticPaymentMethodsOptions
+ {
+ Enabled = true,
+ AllowRedirects = "never",
+ },
+ });
+ return paymentIntent.LatestChargeId!;
+ }
+
+ ///
+ /// Creates an empty Stripe SetupIntent attached to the given customer, returning
+ /// its id. Used by webhook tests that simulate setup_intent.succeeded.
+ ///
+ public async Task CreateBareSetupIntentAsync(string customerId)
+ {
+ var stripeClient = CreateStripeClient();
+ var setupIntent = await stripeClient.V1.SetupIntents.CreateAsync(new SetupIntentCreateOptions
+ {
+ Customer = customerId,
+ PaymentMethodTypes = ["card"],
+ Usage = "off_session",
+ });
+ return setupIntent.Id;
+ }
+
+ ///
+ /// Drives directly
+ /// against an organization. This is what the Admin Portal's POST /organizations/{id}
+ /// "Edit" form does when an admin disables a billing-disabled org. The Stripe fetch
+ /// inside that method runs regardless of subscription status.
+ ///
+ public async Task ScheduleUnpaidCancellationForOrganizationAsync(Guid organizationId)
+ {
+ using var scope = Api.Services.CreateScope();
+ var organization = await scope.ServiceProvider.GetRequiredService()
+ .GetByIdAsync(organizationId);
+ await scope.ServiceProvider.GetRequiredService()
+ .ScheduleUnpaidCancellationAsync(organization!);
+ }
+
+ ///
+ /// Drives directly
+ /// against an organization. The Stripe fetch inside that method runs regardless of
+ /// subscription status.
+ ///
+ public async Task ResumeFromUnpaidCancellationForOrganizationAsync(Guid organizationId)
+ {
+ using var scope = Api.Services.CreateScope();
+ var organization = await scope.ServiceProvider.GetRequiredService()
+ .GetByIdAsync(organizationId);
+ await scope.ServiceProvider.GetRequiredService()
+ .ResumeFromUnpaidCancellationAsync(organization!);
+ }
+
+ ///
+ /// Cancels the user's subscription immediately (not at period end), forcing the user
+ /// off premium and leaving a Canceled subscription on the existing Stripe customer.
+ /// Used by the "existing customer re-subscribes" branch test.
+ ///
+ public async Task CancelUserSubscriptionImmediatelyAsync(Guid userId)
+ {
+ using var scope = Api.Services.CreateScope();
+ var user = await scope.ServiceProvider.GetRequiredService().GetByIdAsync(userId);
+ await scope.ServiceProvider.GetRequiredService()
+ .CancelSubscription(user!, cancelImmediately: true);
+
+ // CancelSubscription updates Stripe state; reflect "no longer premium" on the
+ // user row so the re-subscribe controller path doesn't reject with "Already a
+ // premium user."
+ user!.Premium = false;
+ user.PremiumExpirationDate = null;
+ await scope.ServiceProvider.GetRequiredService().ReplaceAsync(user);
+ }
+
+ ///
+ /// Creates a bare Stripe customer attached to the user (no subscription, no payment
+ /// method) and persists its id on the user row. Drives the
+ /// "user has GatewayCustomerId but no premium" code paths in premium creation and
+ /// in the UserHasNoPreviousSubscriptions discount filter.
+ ///
+ public async Task CreateOrphanedStripeCustomerForUserAsync(Guid userId, string email)
+ {
+ var stripeClient = CreateStripeClient();
+ var customer = await stripeClient.V1.Customers.CreateAsync(new CustomerCreateOptions
+ {
+ Email = email,
+ Description = $"Integration test orphaned customer for {userId}",
+ });
+
+ using var scope = Api.Services.CreateScope();
+ var repo = scope.ServiceProvider.GetRequiredService();
+ var user = await repo.GetByIdAsync(userId);
+ user!.Gateway = GatewayType.Stripe;
+ user.GatewayCustomerId = customer.Id;
+ await repo.ReplaceAsync(user);
+ }
+
+ ///
+ /// Seeds an active with audience
+ /// applicable to the
+ /// Premium product, plus a real Stripe coupon backing it. Returns the coupon id.
+ ///
+ public async Task SeedNoPreviousSubscriptionsDiscountAsync(string couponId)
+ {
+ var stripeClient = CreateStripeClient();
+
+ try { await stripeClient.V1.Coupons.DeleteAsync(couponId); }
+ catch (StripeException ex) when (ex.StripeError?.Code == "resource_missing") { /* coupon doesn't exist yet — first run */ }
+
+ await stripeClient.V1.Coupons.CreateAsync(new CouponCreateOptions
+ {
+ Id = couponId,
+ Name = "Integration Test New-User Discount",
+ PercentOff = 10,
+ Duration = "once",
+ });
+
+ using var scope = Api.Services.CreateScope();
+ var repo = scope.ServiceProvider.GetRequiredService();
+ await repo.CreateAsync(new SubscriptionDiscount
+ {
+ StripeCouponId = couponId,
+ StripeProductIds = [StripeConstants.ProductIDs.Premium],
+ PercentOff = 10,
+ Duration = "once",
+ Name = "New-User Premium Discount",
+ StartDate = DateTime.UtcNow.AddDays(-1),
+ EndDate = DateTime.UtcNow.AddDays(30),
+ AudienceType = DiscountAudienceType.UserHasNoPreviousSubscriptions,
+ });
+ return couponId;
+ }
+
+ ///
+ /// Drives directly. Exercises
+ /// on the org as part of
+ /// unlinking it from a provider.
+ ///
+ public async Task RemoveAnyOrganizationFromProviderAsync(Guid providerId)
+ {
+ using var scope = Api.Services.CreateScope();
+ var providerRepo = scope.ServiceProvider.GetRequiredService();
+ var providerOrgRepo = scope.ServiceProvider.GetRequiredService();
+ var orgRepo = scope.ServiceProvider.GetRequiredService();
+ var command = scope.ServiceProvider.GetRequiredService();
+
+ var provider = await providerRepo.GetByIdAsync(providerId);
+ var providerOrgDetails = (await providerOrgRepo.GetManyDetailsByProviderAsync(providerId)).First();
+ var providerOrg = await providerOrgRepo.GetByIdAsync(providerOrgDetails.Id);
+ var organization = await orgRepo.GetByIdAsync(providerOrgDetails.OrganizationId);
+
+ await command.RemoveOrganizationFromProvider(provider!, providerOrg!, organization!);
+ }
+
+ ///
+ /// Detaches the customer's default Stripe payment method. Drives the
+ /// no-default-PM branch inside GetPaymentSourceAsync which lists setup intents
+ /// with Expand=["data.payment_method"].
+ ///
+ public async Task DetachDefaultPaymentMethodAsync(string customerId)
+ {
+ var stripeClient = CreateStripeClient();
+ var customer = await stripeClient.V1.Customers.GetAsync(customerId);
+ if (!string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId))
+ {
+ await stripeClient.V1.PaymentMethods.DetachAsync(customer.InvoiceSettings.DefaultPaymentMethodId);
+ }
+ }
+
+ ///
+ /// Creates a Stripe coupon (deletes any pre-existing one first), inserts a churn-only
+ /// migration cohort that references it, and assigns the organization to that cohort.
+ /// Drives the and
+ /// Expand-using fetches.
+ ///
+ public async Task SeedChurnOnlyCohortAsync(Guid organizationId, string couponId)
+ {
+ var stripeClient = CreateStripeClient();
+
+ try { await stripeClient.V1.Coupons.DeleteAsync(couponId); }
+ catch (StripeException ex) when (ex.StripeError?.Code == "resource_missing") { /* coupon doesn't exist yet — first run */ }
+
+ await stripeClient.V1.Coupons.CreateAsync(new CouponCreateOptions
+ {
+ Id = couponId,
+ Name = "Integration Test Churn Coupon",
+ PercentOff = 25,
+ Duration = "repeating",
+ DurationInMonths = 3,
+ });
+
+ using var scope = Api.Services.CreateScope();
+ var cohortRepository = scope.ServiceProvider
+ .GetRequiredService();
+ var cohort = await cohortRepository.CreateAsync(new OrganizationPlanMigrationCohort
+ {
+ Name = $"churn-only-{Guid.NewGuid():N}",
+ MigrationPathId = null,
+ ChurnDiscountCouponCode = couponId,
+ IsActive = true,
+ });
+
+ var assignmentRepository = scope.ServiceProvider
+ .GetRequiredService();
+ await assignmentRepository.CreateAsync(new OrganizationPlanMigrationCohortAssignment
+ {
+ OrganizationId = organizationId,
+ CohortId = cohort.Id,
+ });
+
+ return couponId;
+ }
+
+ ///
+ /// Seeds a migration-cohort assignment and creates an active Stripe SubscriptionSchedule
+ /// on the organization's subscription so the
+ /// and schedule-Expand sites are reachable.
+ ///
+ public async Task SeedMigrationCohortWithScheduleAsync(Guid organizationId, MigrationPathId migrationPathId)
+ {
+ var stripeClient = CreateStripeClient();
+ var subscriptionId = await GetOrganizationGatewaySubscriptionIdAsync(organizationId);
+
+ await stripeClient.V1.SubscriptionSchedules.CreateAsync(new SubscriptionScheduleCreateOptions
+ {
+ FromSubscription = subscriptionId,
+ });
+
+ using var scope = Api.Services.CreateScope();
+ var cohortRepository = scope.ServiceProvider
+ .GetRequiredService();
+ var cohort = await cohortRepository.CreateAsync(new OrganizationPlanMigrationCohort
+ {
+ Name = $"migration-{Guid.NewGuid():N}",
+ MigrationPathId = migrationPathId,
+ IsActive = true,
+ });
+
+ var assignmentRepository = scope.ServiceProvider
+ .GetRequiredService();
+ await assignmentRepository.CreateAsync(new OrganizationPlanMigrationCohortAssignment
+ {
+ OrganizationId = organizationId,
+ CohortId = cohort.Id,
+ });
+ }
+
+ ///
+ /// Creates a no-op Stripe Checkout Session attached to the given customer for
+ /// webhook tests that simulate checkout.session.completed.
+ ///
+ public async Task CreateCheckoutSessionAsync(string customerId)
+ {
+ var stripeClient = CreateStripeClient();
+ var session = await stripeClient.V1.Checkout.Sessions.CreateAsync(new Stripe.Checkout.SessionCreateOptions
+ {
+ Customer = customerId,
+ Mode = "setup",
+ PaymentMethodTypes = ["card"],
+ SuccessUrl = "https://example.com/success",
+ CancelUrl = "https://example.com/cancel",
+ });
+ return session.Id;
+ }
+
+ public virtual async ValueTask DisposeAsync()
+ {
+ await Admin.DisposeAsync();
+ await Api.DisposeAsync();
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/test/Billing.IntegrationTest/StripeWebhookTests.cs b/test/Billing.IntegrationTest/StripeWebhookTests.cs
new file mode 100644
index 000000000000..9cb90995a767
--- /dev/null
+++ b/test/Billing.IntegrationTest/StripeWebhookTests.cs
@@ -0,0 +1,128 @@
+using System.Text.Json.Nodes;
+
+namespace Bit.Billing.IntegrationTest;
+
+public class StripeWebhookTests(StripeWebhookTestsFixture fixture) : IClassFixture
+{
+ [BillingFact]
+ public async Task CustomerUpdated_ReFetchesTheCustomerWithSubscriptionsExpanded()
+ {
+ // Drives CustomerUpdatedHandler -> StripeEventService.GetCustomer(parsedEvent, fresh: true, ["subscriptions"]),
+ // which executes the Expand inside StripeEventService.
+ var (_, _, organizationId, _) =
+ await fixture.PrepareOrganizationOwnerAsync("webhook-customer-updated@example.com");
+ var customerId = await fixture.GetOrganizationGatewayCustomerIdAsync(organizationId);
+
+ await fixture.Billing.SendStripeWebhookAsync(
+ "customer.updated",
+ new JsonObject { ["id"] = customerId, ["object"] = "customer" },
+ $"evt_{Guid.NewGuid():N}");
+ }
+
+ [BillingFact]
+ public async Task SubscriptionUpdated_ReFetchesTheSubscriptionWithCustomerLatestInvoiceTestClockExpanded()
+ {
+ // Drives SubscriptionUpdatedHandler -> StripeEventService.GetSubscription(parsedEvent, fresh: true,
+ // ["customer.discount", "discounts", "latest_invoice", "test_clock"]).
+ var (_, _, organizationId, _) =
+ await fixture.PrepareOrganizationOwnerAsync("webhook-subscription-updated@example.com");
+ var subscriptionId = await fixture.GetOrganizationGatewaySubscriptionIdAsync(organizationId);
+
+ await fixture.Billing.SendStripeWebhookAsync(
+ "customer.subscription.updated",
+ new JsonObject { ["id"] = subscriptionId, ["object"] = "subscription" },
+ $"evt_{Guid.NewGuid():N}");
+ }
+
+ [BillingFact]
+ public async Task InvoiceUpcoming_ReFetchesTheInvoiceWithCustomerAndSubscriptionExpanded()
+ {
+ // Drives UpcomingInvoiceHandler -> StripeEventService.GetInvoice (and an additional Expand call inside the handler).
+ var (_, _, organizationId, _) =
+ await fixture.PrepareOrganizationOwnerAsync("webhook-invoice-upcoming@example.com");
+ var customerId = await fixture.GetOrganizationGatewayCustomerIdAsync(organizationId);
+ var subscriptionId = await fixture.GetOrganizationGatewaySubscriptionIdAsync(organizationId);
+
+ // Stripe will create a real upcoming-invoice preview for this subscription on demand;
+ // we synthesize the event payload with the IDs and let the handler re-fetch via the API.
+ await fixture.Billing.SendStripeWebhookAsync(
+ "invoice.upcoming",
+ new JsonObject
+ {
+ ["id"] = $"in_upcoming_{Guid.NewGuid():N}",
+ ["object"] = "invoice",
+ ["customer"] = customerId,
+ ["subscription"] = subscriptionId,
+ },
+ $"evt_{Guid.NewGuid():N}");
+ }
+
+ [BillingFact]
+ public async Task PaymentMethodAttached_ReFetchesThePaymentMethodWithCustomerSubscriptionsExpanded()
+ {
+ // Drives PaymentMethodAttachedHandler -> StripeEventService.GetPaymentMethod with
+ // Expand=["customer.subscriptions.data.latest_invoice"].
+ var (_, _, organizationId, _) =
+ await fixture.PrepareOrganizationOwnerAsync("webhook-pm-attached@example.com");
+ var customerId = await fixture.GetOrganizationGatewayCustomerIdAsync(organizationId);
+
+ await fixture.Billing.SendStripeWebhookAsync(
+ "payment_method.attached",
+ new JsonObject
+ {
+ ["id"] = "pm_card_visa",
+ ["object"] = "payment_method",
+ ["customer"] = customerId,
+ },
+ $"evt_{Guid.NewGuid():N}");
+ }
+
+ [BillingFact]
+ public async Task SetupIntentSucceeded_ReFetchesTheSetupIntentWithExpand()
+ {
+ // Drives SetupIntentSucceededHandler -> StripeEventService.GetSetupIntent.
+ var (_, _, organizationId, _) =
+ await fixture.PrepareOrganizationOwnerAsync("webhook-setup-intent-succeeded@example.com");
+
+ // Create a fresh SetupIntent via the Stripe SDK so we have a real id to reference.
+ var setupIntentId = await fixture.CreateBareSetupIntentAsync(
+ await fixture.GetOrganizationGatewayCustomerIdAsync(organizationId));
+
+ await fixture.Billing.SendStripeWebhookAsync(
+ "setup_intent.succeeded",
+ new JsonObject { ["id"] = setupIntentId, ["object"] = "setup_intent" },
+ $"evt_{Guid.NewGuid():N}");
+ }
+
+ [BillingFact]
+ public async Task ChargeSucceeded_ReFetchesTheChargeFromStripe()
+ {
+ // Drives ChargeSucceededHandler -> StripeEventService.GetCharge.
+ // Org signup with pm_card_visa produces an immediate Stripe charge; we look it up.
+ var (_, _, organizationId, _) =
+ await fixture.PrepareOrganizationOwnerAsync("webhook-charge-succeeded@example.com");
+ var chargeId = await fixture.CreateChargeForOrganizationAsync(organizationId);
+
+ await fixture.Billing.SendStripeWebhookAsync(
+ "charge.succeeded",
+ new JsonObject { ["id"] = chargeId, ["object"] = "charge" },
+ $"evt_{Guid.NewGuid():N}");
+ }
+
+ [BillingFact]
+ public async Task CheckoutSessionCompleted_ReFetchesTheSessionWithSubscriptionExpanded()
+ {
+ // Drives CheckoutSessionCompletedHandler -> StripeAdapter.GetCheckoutSessionAsync ->
+ // StripeEventService.GetCheckoutSession with Expand=["subscription"].
+ var (_, _, organizationId, _) =
+ await fixture.PrepareOrganizationOwnerAsync("webhook-checkout-completed@example.com");
+ var customerId = await fixture.GetOrganizationGatewayCustomerIdAsync(organizationId);
+
+ var sessionId = await fixture.CreateCheckoutSessionAsync(customerId);
+
+ await fixture.Billing.SendStripeWebhookAsync(
+ "checkout.session.completed",
+ new JsonObject { ["id"] = sessionId, ["object"] = "checkout.session" },
+ $"evt_{Guid.NewGuid():N}");
+ }
+}
diff --git a/test/Billing.IntegrationTest/StripeWebhookTestsFixture.cs b/test/Billing.IntegrationTest/StripeWebhookTestsFixture.cs
new file mode 100644
index 000000000000..7b931922e1d8
--- /dev/null
+++ b/test/Billing.IntegrationTest/StripeWebhookTestsFixture.cs
@@ -0,0 +1,23 @@
+namespace Bit.Billing.IntegrationTest;
+
+///
+/// Variant of that additionally spins up the
+/// Bit.Billing webhook host sharing the API host's database, so webhook tests
+/// can post Stripe events that reference real subscribers seeded through the
+/// existing intent methods.
+///
+public sealed class StripeWebhookTestsFixture : StripeTestsFixture
+{
+ public BillingApplicationFactory Billing { get; }
+
+ public StripeWebhookTestsFixture()
+ {
+ Billing = new BillingApplicationFactory(Api.TestDatabase);
+ }
+
+ public override async ValueTask DisposeAsync()
+ {
+ await Billing.DisposeAsync();
+ await base.DisposeAsync();
+ }
+}
diff --git a/test/Billing.IntegrationTest/SubscriptionPreviewTests.cs b/test/Billing.IntegrationTest/SubscriptionPreviewTests.cs
new file mode 100644
index 000000000000..e15b5d0e10f1
--- /dev/null
+++ b/test/Billing.IntegrationTest/SubscriptionPreviewTests.cs
@@ -0,0 +1,50 @@
+using System.Net.Http.Json;
+using System.Text.Json.Nodes;
+using Bit.Test.Common.Helpers;
+
+namespace Bit.Billing.IntegrationTest;
+
+public class SubscriptionPreviewTests(StripeTestsFixture fixture) : IClassFixture
+{
+ [BillingFact]
+ public async Task PlanChange_ToTeamsAnnually_ReturnsTaxAndTotal()
+ {
+ var (client, _, organizationId, _) =
+ await fixture.PrepareOrganizationOwnerAsync("preview-plan-change@example.com");
+
+ var response = await client.PostAsJsonAsync(
+ $"/billing/preview-invoice/organizations/{organizationId}/subscription/plan-change",
+ new
+ {
+ Plan = new { Tier = "Teams", Cadence = "Annually" },
+ BillingAddress = new { Country = "US", PostalCode = "43432" },
+ });
+ await Assert.SuccessResponseAsync(response);
+
+ var preview = (await response.Content.ReadFromJsonAsync())!;
+ Assert.NotNull(preview["tax"]);
+ Assert.NotNull(preview["total"]);
+ }
+
+ [BillingFact]
+ public async Task SubscriptionUpdate_WithAdditionalSeats_ReturnsTaxAndTotal()
+ {
+ var (client, _, organizationId, _) =
+ await fixture.PrepareOrganizationOwnerAsync("preview-update@example.com");
+
+ var response = await client.PutAsJsonAsync(
+ $"/billing/preview-invoice/organizations/{organizationId}/subscription/update",
+ new
+ {
+ Update = new
+ {
+ PasswordManager = new { Seats = 15 },
+ },
+ });
+ await Assert.SuccessResponseAsync(response);
+
+ var preview = (await response.Content.ReadFromJsonAsync())!;
+ Assert.NotNull(preview["tax"]);
+ Assert.NotNull(preview["total"]);
+ }
+}
diff --git a/test/Billing.IntegrationTest/UnpaidCancellationTests.cs b/test/Billing.IntegrationTest/UnpaidCancellationTests.cs
new file mode 100644
index 000000000000..2bf649bf2200
--- /dev/null
+++ b/test/Billing.IntegrationTest/UnpaidCancellationTests.cs
@@ -0,0 +1,27 @@
+namespace Bit.Billing.IntegrationTest;
+
+public class UnpaidCancellationTests(StripeTestsFixture fixture) : IClassFixture
+{
+ [BillingFact]
+ public async Task ScheduleUnpaidCancellation_FetchesSubscriptionWithTestClockExpanded()
+ {
+ var (_, _, organizationId, _) =
+ await fixture.PrepareOrganizationOwnerAsync("schedule-unpaid-cancel@example.com");
+
+ // Drives ScheduleUnpaidCancellationAsync -> GetSubscription with Expand=["test_clock"].
+ // The subsequent status guard short-circuits because the sub isn't actually Unpaid,
+ // but the Expand-using fetch has already executed.
+ await fixture.ScheduleUnpaidCancellationForOrganizationAsync(organizationId);
+ }
+
+ [BillingFact]
+ public async Task ResumeUnpaidCancellation_FetchesSubscriptionWithCustomerDiscountExpanded()
+ {
+ var (_, _, organizationId, _) =
+ await fixture.PrepareOrganizationOwnerAsync("resume-unpaid-cancel@example.com");
+
+ // Drives ResumeFromUnpaidCancellationAsync -> GetSubscription with
+ // Expand=["customer.discount", "discounts"].
+ await fixture.ResumeFromUnpaidCancellationForOrganizationAsync(organizationId);
+ }
+}
diff --git a/test/Billing.IntegrationTest/UpdatingOrganizationBillingTests.cs b/test/Billing.IntegrationTest/UpdatingOrganizationBillingTests.cs
new file mode 100644
index 000000000000..0f545cbabc89
--- /dev/null
+++ b/test/Billing.IntegrationTest/UpdatingOrganizationBillingTests.cs
@@ -0,0 +1,34 @@
+using System.Net.Http.Json;
+using System.Text.Json.Nodes;
+using Bit.Test.Common.Helpers;
+
+namespace Bit.Billing.IntegrationTest;
+
+public class UpdatingOrganizationBillingTests(StripeTestsFixture fixture) : IClassFixture
+{
+ [BillingFact]
+ public async Task BillingAddress_WhenAddressFieldsProvided_PersistsAllFields()
+ {
+ var (client, _, organizationId, _) =
+ await fixture.PrepareOrganizationOwnerAsync("update-billing-address@example.com");
+
+ var response = await client.PutAsJsonAsync(
+ $"/organizations/{organizationId}/billing/vnext/address",
+ new
+ {
+ Country = "US",
+ PostalCode = "10001",
+ Line1 = "123 Test St",
+ City = "New York",
+ State = "NY",
+ });
+ await Assert.SuccessResponseAsync(response);
+
+ var billingAddress = (await response.Content.ReadFromJsonAsync())!;
+ Assert.Equal("US", billingAddress["country"]!.GetValue());
+ Assert.Equal("10001", billingAddress["postalCode"]!.GetValue());
+ Assert.Equal("123 Test St", billingAddress["line1"]!.GetValue());
+ Assert.Equal("New York", billingAddress["city"]!.GetValue());
+ Assert.Equal("NY", billingAddress["state"]!.GetValue());
+ }
+}
diff --git a/test/Billing.IntegrationTest/UpdatingPaymentMethodTests.cs b/test/Billing.IntegrationTest/UpdatingPaymentMethodTests.cs
new file mode 100644
index 000000000000..f50823f1f701
--- /dev/null
+++ b/test/Billing.IntegrationTest/UpdatingPaymentMethodTests.cs
@@ -0,0 +1,36 @@
+using System.Net.Http.Json;
+using System.Text.Json.Nodes;
+using Bit.Test.Common.Helpers;
+
+namespace Bit.Billing.IntegrationTest;
+
+public class UpdatingPaymentMethodTests(StripeTestsFixture fixture) : IClassFixture
+{
+ [BillingFact]
+ public async Task BankAccount_WhenSetupIntentExists_AttachesItToTheCustomer()
+ {
+ const string email = "update-payment-method-bank-account@example.com";
+
+ var (client, _, organizationId, _) = await fixture.PrepareOrganizationOwnerAsync(email);
+ var bankAccountToken = await fixture.CreateConfirmedBankAccountSetupIntentAsync(email);
+
+ // Drives UpdatePaymentMethodCommand.AddBankAccountAsync, which calls
+ // ListSetupIntentsAsync with Expand=data.payment_method to find the
+ // confirmed intent and then attaches it to the subscriber's customer.
+ // MaskedPaymentMethod.From(SetupIntent) reads setupIntent.PaymentMethod
+ // .UsBankAccount.BankName/Last4, which requires the expand.
+ var response = await client.PutAsJsonAsync(
+ $"/organizations/{organizationId}/billing/vnext/payment-method",
+ new
+ {
+ Type = "bankAccount",
+ Token = bankAccountToken,
+ });
+ await Assert.SuccessResponseAsync(response);
+
+ var paymentMethod = (await response.Content.ReadFromJsonAsync())!;
+ Assert.Equal("bankAccount", paymentMethod["type"]!.GetValue());
+ Assert.NotNull(paymentMethod["bankName"]);
+ Assert.NotNull(paymentMethod["last4"]);
+ }
+}
diff --git a/test/Billing.IntegrationTest/UpdatingPersonalBillingAddressTests.cs b/test/Billing.IntegrationTest/UpdatingPersonalBillingAddressTests.cs
new file mode 100644
index 000000000000..a88883151757
--- /dev/null
+++ b/test/Billing.IntegrationTest/UpdatingPersonalBillingAddressTests.cs
@@ -0,0 +1,36 @@
+using System.Net.Http.Json;
+using System.Text.Json.Nodes;
+using Bit.Core.Billing.Enums;
+using Bit.Test.Common.Helpers;
+
+namespace Bit.Billing.IntegrationTest;
+
+public class UpdatingPersonalBillingAddressTests(StripeTestsFixture fixture) : IClassFixture
+{
+ [BillingFact]
+ public async Task BillingAddress_ForFamiliesOrganization_PersistsViaPersonalPath()
+ {
+ var (client, _, organizationId, _) =
+ await fixture.PrepareOrganizationOwnerAsync("families-billing-address@example.com", PlanType.FamiliesAnnually);
+
+ // Families is a personal-tier product, so UpdateBillingAddressCommand
+ // dispatches to UpdatePersonalBillingAddressAsync, which expands
+ // ["subscriptions", "subscriptions.data.test_clock"] and then walks
+ // customer.Subscriptions to enable automatic tax on the active sub.
+ var response = await client.PutAsJsonAsync(
+ $"/organizations/{organizationId}/billing/vnext/address",
+ new
+ {
+ Country = "US",
+ PostalCode = "10001",
+ Line1 = "1 Family Way",
+ City = "New York",
+ State = "NY",
+ });
+ await Assert.SuccessResponseAsync(response);
+
+ var billingAddress = (await response.Content.ReadFromJsonAsync())!;
+ Assert.Equal("US", billingAddress["country"]!.GetValue());
+ Assert.Equal("10001", billingAddress["postalCode"]!.GetValue());
+ }
+}
diff --git a/test/Billing.IntegrationTest/UpdatingPremiumSubscriptionTests.cs b/test/Billing.IntegrationTest/UpdatingPremiumSubscriptionTests.cs
new file mode 100644
index 000000000000..4d2d3222cf54
--- /dev/null
+++ b/test/Billing.IntegrationTest/UpdatingPremiumSubscriptionTests.cs
@@ -0,0 +1,49 @@
+using System.Net.Http.Json;
+using System.Text.Json.Nodes;
+using Bit.Test.Common.Helpers;
+
+namespace Bit.Billing.IntegrationTest;
+
+public class UpdatingPremiumSubscriptionTests(StripeTestsFixture fixture) : IClassFixture
+{
+ [BillingFact]
+ public async Task Storage_WhenAdditionalGbProvided_PersistsAndReturnsNewStorageState()
+ {
+ var client = await fixture.PreparePremiumUserAsync("premium-storage@example.com");
+
+ // Drives UpdatePremiumStorageCommand, which fetches the subscription with
+ // Expand=customer, test_clock; the customer and test_clock fields are then
+ // read while adjusting storage line items + finalizing the proration invoice.
+ var response = await client.PutAsJsonAsync(
+ "/account/billing/vnext/subscription/storage",
+ new { AdditionalStorageGb = (short)1 });
+ await Assert.SuccessResponseAsync(response);
+
+ var subscription = await client.GetAsync("/account/billing/vnext/subscription");
+ await Assert.SuccessResponseAsync(subscription);
+ var body = (await subscription.Content.ReadFromJsonAsync())!;
+ Assert.NotNull(body["storage"]);
+ }
+
+ [BillingFact]
+ public async Task UpgradePreview_ToFamiliesPlan_ReturnsTheProratedPricing()
+ {
+ var client = await fixture.PreparePremiumUserAsync("premium-upgrade-preview@example.com");
+
+ // Drives PreviewPremiumUpgradeProrationCommand, which fetches the
+ // existing premium subscription with Expand=customer to read
+ // subscription.Customer for tax + billing context.
+ var response = await client.PostAsJsonAsync(
+ "/billing/preview-invoice/premium/subscriptions/upgrade",
+ new
+ {
+ TargetProductTierType = "Families",
+ BillingAddress = new { Country = "US", PostalCode = "43432" },
+ });
+ await Assert.SuccessResponseAsync(response);
+
+ var preview = (await response.Content.ReadFromJsonAsync())!;
+ Assert.NotNull(preview["newPlanProratedAmount"]);
+ Assert.NotNull(preview["credit"]);
+ }
+}
diff --git a/test/Billing.IntegrationTest/UpdatingSecretsManagerSubscriptionTests.cs b/test/Billing.IntegrationTest/UpdatingSecretsManagerSubscriptionTests.cs
new file mode 100644
index 000000000000..00006bcc8450
--- /dev/null
+++ b/test/Billing.IntegrationTest/UpdatingSecretsManagerSubscriptionTests.cs
@@ -0,0 +1,29 @@
+using System.Net.Http.Json;
+using Bit.Test.Common.Helpers;
+
+namespace Bit.Billing.IntegrationTest;
+
+public class UpdatingSecretsManagerSubscriptionTests(StripeTestsFixture fixture) : IClassFixture
+{
+ [BillingFact]
+ public async Task SeatAdjustment_AfterSubscribingToSecretsManager_PersistsTheNewSeatCount()
+ {
+ var (client, _, organizationId, _) =
+ await fixture.PrepareOrganizationOwnerAsync("sm-subscription@example.com");
+
+ // Subscribe the org to Secrets Manager so the sm-subscription endpoint
+ // has line items to adjust.
+ var subscribeResponse = await client.PostAsJsonAsync(
+ $"/organizations/{organizationId}/subscribe-secrets-manager",
+ new { AdditionalSmSeats = 2, AdditionalServiceAccounts = 0 });
+ await Assert.SuccessResponseAsync(subscribeResponse);
+
+ // Drives UpdateSecretsManagerSubscriptionCommand, which fetches the
+ // subscription with Expand=customer, test_clock to reconcile seat
+ // changes against the live subscription state.
+ var adjustResponse = await client.PostAsJsonAsync(
+ $"/organizations/{organizationId}/sm-subscription",
+ new { SeatAdjustment = 3, ServiceAccountAdjustment = 0 });
+ await Assert.SuccessResponseAsync(adjustResponse);
+ }
+}
diff --git a/test/Billing.IntegrationTest/UpgradingOrganizationPlanTests.cs b/test/Billing.IntegrationTest/UpgradingOrganizationPlanTests.cs
new file mode 100644
index 000000000000..10731adbc807
--- /dev/null
+++ b/test/Billing.IntegrationTest/UpgradingOrganizationPlanTests.cs
@@ -0,0 +1,32 @@
+using System.Net.Http.Json;
+using Bit.Core.Billing.Enums;
+using Bit.Test.Common.Helpers;
+
+namespace Bit.Billing.IntegrationTest;
+
+public class UpgradingOrganizationPlanTests(StripeTestsFixture fixture) : IClassFixture
+{
+ [BillingFact]
+ public async Task FamiliesToEnterprise_ReusesTheExistingStripeCustomer()
+ {
+ var (client, _, organizationId, _) =
+ await fixture.PrepareOrganizationOwnerAsync("upgrade-families-to-enterprise@example.com", PlanType.FamiliesAnnually);
+
+ // Drives UpgradeOrganizationPlanVNextCommand, which (for an org that
+ // already has a Stripe customer + subscription) routes through
+ // UpdateOrganizationSubscriptionCommand and fetches the subscription
+ // with Expand=customer, test_clock to read subscription.Customer for
+ // tax reconciliation against the new plan's price.
+ var response = await client.PostAsJsonAsync(
+ $"/organizations/{organizationId}/upgrade",
+ new
+ {
+ PlanType = PlanType.EnterpriseAnnually,
+ AdditionalSeats = 5,
+ UseSecretsManager = false,
+ BillingAddressCountry = "US",
+ BillingAddressPostalCode = "43432",
+ });
+ await Assert.SuccessResponseAsync(response);
+ }
+}
diff --git a/test/Billing.IntegrationTest/packages.lock.json b/test/Billing.IntegrationTest/packages.lock.json
new file mode 100644
index 000000000000..3add696ca043
--- /dev/null
+++ b/test/Billing.IntegrationTest/packages.lock.json
@@ -0,0 +1,2071 @@
+{
+ "version": 1,
+ "dependencies": {
+ "net10.0": {
+ "coverlet.collector": {
+ "type": "Direct",
+ "requested": "[6.0.0, )",
+ "resolved": "6.0.0",
+ "contentHash": "tW3lsNS+dAEII6YGUX/VMoJjBS1QvsxqJeqLaJXub08y1FSjasFPtQ4UBUsudE9PNrzLjooClMsPtY2cZLdXpQ=="
+ },
+ "Microsoft.NET.Test.Sdk": {
+ "type": "Direct",
+ "requested": "[18.0.1, )",
+ "resolved": "18.0.1",
+ "contentHash": "WNpu6vI2rA0pXY4r7NKxCN16XRWl5uHu6qjuyVLoDo6oYEggIQefrMjkRuibQHm/NslIUNCcKftvoWAN80MSAg==",
+ "dependencies": {
+ "Microsoft.CodeCoverage": "18.0.1",
+ "Microsoft.TestPlatform.TestHost": "18.0.1"
+ }
+ },
+ "NSubstitute": {
+ "type": "Direct",
+ "requested": "[5.1.0, )",
+ "resolved": "5.1.0",
+ "contentHash": "ZCqOP3Kpp2ea7QcLyjMU4wzE+0wmrMN35PQMsdPOHYc2IrvjmusG9hICOiqiOTPKN0gJon6wyCn6ZuGHdNs9hQ==",
+ "dependencies": {
+ "Castle.Core": "5.1.1"
+ }
+ },
+ "xunit": {
+ "type": "Direct",
+ "requested": "[2.6.6, )",
+ "resolved": "2.6.6",
+ "contentHash": "MAbOOMtZIKyn2lrAmMlvhX0BhDOX/smyrTB+8WTXnSKkrmTGBS2fm8g1PZtHBPj91Dc5DJA7fY+/81TJ/yUFZw==",
+ "dependencies": {
+ "xunit.analyzers": "1.10.0",
+ "xunit.assert": "2.6.6",
+ "xunit.core": "[2.6.6]"
+ }
+ },
+ "xunit.runner.visualstudio": {
+ "type": "Direct",
+ "requested": "[2.5.6, )",
+ "resolved": "2.5.6",
+ "contentHash": "CW6uhMXNaQQNMSG1IWhHkBT+V5eqHqn7MP0zfNMhU9wS/sgKX7FGL3rzoaUgt26wkY3bpf7pDVw3IjXhwfiP4w=="
+ },
+ "AdaptiveCards": {
+ "type": "Transitive",
+ "resolved": "3.1.0",
+ "contentHash": "b+sPwH0oyAflpgxCyNPMzH92xrQjWl6GuuEBv86/VhO6iHhiWv+PtwzqMS70nOXZQRzpl9YVHXAvn+dKot5IBQ==",
+ "dependencies": {
+ "Newtonsoft.Json": "13.0.3"
+ }
+ },
+ "AspNetCore.HealthChecks.SqlServer": {
+ "type": "Transitive",
+ "resolved": "8.0.2",
+ "contentHash": "sTcVVq7/zhfUrSTs0WAktvPdpU1He/sj14gRTogq4eFhn0oImolxNNhJczkYMgFF92RMMW+O+rlcFO7HVOpfiQ==",
+ "dependencies": {
+ "Microsoft.Data.SqlClient": "5.2.0",
+ "Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.0"
+ }
+ },
+ "AspNetCore.HealthChecks.Uris": {
+ "type": "Transitive",
+ "resolved": "8.0.1",
+ "contentHash": "A1ahRx4pdXjrSGlGFLoyoXOV4Lfp5sfs+OIGfvi14RwecIAac4xs6cP0Q8tw/rv4Ng+KAaYpzD4qhxXVwUcIyA==",
+ "dependencies": {
+ "Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.0",
+ "Microsoft.Extensions.Http": "8.0.0"
+ }
+ },
+ "AspNetCoreRateLimit": {
+ "type": "Transitive",
+ "resolved": "5.0.0",
+ "contentHash": "6fq9+o1maGADUmpK/PvcF0DtXW2+7bSkIL7MDIo/agbIHKN8XkMQF4oze60DO731WaQmHmK260hB30FwPzCmEg==",
+ "dependencies": {
+ "Microsoft.Extensions.Caching.Abstractions": "6.0.0",
+ "Microsoft.Extensions.Logging.Abstractions": "6.0.3",
+ "Microsoft.Extensions.Options": "6.0.0",
+ "Newtonsoft.Json": "13.0.2"
+ }
+ },
+ "AspNetCoreRateLimit.Redis": {
+ "type": "Transitive",
+ "resolved": "2.0.0",
+ "contentHash": "3g6Mb4Y+rW14/oE7Qt8WFA9zS7XNdHx7TH3k/XQix7PtUWEZSSAK+VsqDhDIPkUysUHFBDUA7olNeTjytpzA/g==",
+ "dependencies": {
+ "AspNetCoreRateLimit": "5.0.0",
+ "StackExchange.Redis": "2.6.80"
+ }
+ },
+ "AutoFixture": {
+ "type": "Transitive",
+ "resolved": "4.18.1",
+ "contentHash": "BmWZDY4fkrYOyd5/CTBOeXbzsNwV8kI4kDi/Ty1Y5F+WDHBVKxzfWlBE4RSicvZ+EOi2XDaN5uwdrHsItLW6Kw==",
+ "dependencies": {
+ "Fare": "[2.1.1, 3.0.0)"
+ }
+ },
+ "AutoFixture.AutoNSubstitute": {
+ "type": "Transitive",
+ "resolved": "4.18.1",
+ "contentHash": "xJxIsShO/1Ceei7BDFCobFANiw5a+enpdklgX/Xic6vKavHo9gSJO7ZGkKlf2lh+TlblTEet9mjzf9wHWIqWGQ==",
+ "dependencies": {
+ "AutoFixture": "4.18.1",
+ "NSubstitute": "[2.0.3, 6.0.0)"
+ }
+ },
+ "AutoFixture.Xunit2": {
+ "type": "Transitive",
+ "resolved": "4.18.1",
+ "contentHash": "I5Cwv1bvWb0lf2x2zO42bBQ2WaGudBh7tVBCzKIf8KmRJG+hmYY7ku3znnFZDVxbQaihNaqNkztLTwK4PwaoWg==",
+ "dependencies": {
+ "AutoFixture": "4.18.1",
+ "xunit.extensibility.core": "[2.2.0, 3.0.0)"
+ }
+ },
+ "AutoMapper": {
+ "type": "Transitive",
+ "resolved": "14.0.0",
+ "contentHash": "OC+1neAPM4oCCqQj3g2GJ2shziNNhOkxmNB9cVS8jtx4JbgmRzLcUOxB9Tsz6cVPHugdkHgCaCrTjjSI0Z5sCQ==",
+ "dependencies": {
+ "Microsoft.Extensions.Options": "8.0.0"
+ }
+ },
+ "AWSSDK.Core": {
+ "type": "Transitive",
+ "resolved": "4.0.3.3",
+ "contentHash": "YQv10JuxnciWh0QwnkarSbge4gXQV1qTURf5jkBjNUH/3jYS9QrbxopA4TK1qdjfOfP37tqiJkLSrRRNqX81aw=="
+ },
+ "AWSSDK.SimpleEmail": {
+ "type": "Transitive",
+ "resolved": "4.0.2.5",
+ "contentHash": "LvV5mXlvpR3fTAJysO3KmUC6bR/KUZpdkcMJ5b6lYNpStlsFN+MXcaMh34TuwYaTCgIjF3bJb4oZifFkgh+Ccw==",
+ "dependencies": {
+ "AWSSDK.Core": "[4.0.3.3, 5.0.0)"
+ }
+ },
+ "AWSSDK.SQS": {
+ "type": "Transitive",
+ "resolved": "4.0.2.5",
+ "contentHash": "bHA9m/2RZHNKt6NGvQ56rfEDj/pfUlcwPeCg2HmPJ3jZPyoerBuEsYvFkMP3YJNv3aoycNWZoiDk0/ULP0tEyA==",
+ "dependencies": {
+ "AWSSDK.Core": "[4.0.3.3, 5.0.0)"
+ }
+ },
+ "Azure.Core": {
+ "type": "Transitive",
+ "resolved": "1.47.3",
+ "contentHash": "u/uCNtUWT+Q/Is7/PAMy3KP9kq5vY5klRnyAvRxO/kEa5OnV3/X5lHlCajNANC7vmej6jAqceqLBJNO/VyCKzg==",
+ "dependencies": {
+ "Microsoft.Bcl.AsyncInterfaces": "8.0.0",
+ "System.ClientModel": "1.6.1",
+ "System.Memory.Data": "8.0.1"
+ }
+ },
+ "Azure.Core.Amqp": {
+ "type": "Transitive",
+ "resolved": "1.3.1",
+ "contentHash": "AY1ZM4WwLBb9L2WwQoWs7wS2XKYg83tp3yVVdgySdebGN0FuIszuEqCy3Nhv6qHpbkjx/NGuOTsUbF/oNGBgwA==",
+ "dependencies": {
+ "Microsoft.Azure.Amqp": "2.6.7",
+ "System.Memory.Data": "1.0.2"
+ }
+ },
+ "Azure.Data.Tables": {
+ "type": "Transitive",
+ "resolved": "12.11.0",
+ "contentHash": "MabH2HegMvZA1ocaMhEfW/idyTa3CoH64s43/V9/KFRGdVqEj0EETvd3ItDe6Bbs2teiR40KE9Kz9NLDc5DJJw==",
+ "dependencies": {
+ "Azure.Core": "1.44.1"
+ }
+ },
+ "Azure.Extensions.AspNetCore.DataProtection.Blobs": {
+ "type": "Transitive",
+ "resolved": "1.3.4",
+ "contentHash": "zS+x0MpUMSbvZD598lwAoax+ohIeSAvGlXpT71iP7FFmMZ+Tjz/8hx+jZH/RbV2cJYTYbux8XFDll7LMPuz46g==",
+ "dependencies": {
+ "Azure.Core": "1.38.0",
+ "Azure.Storage.Blobs": "12.16.0",
+ "Microsoft.AspNetCore.DataProtection": "3.1.32"
+ }
+ },
+ "Azure.Identity": {
+ "type": "Transitive",
+ "resolved": "1.11.4",
+ "contentHash": "Sf4BoE6Q3jTgFkgBkx7qztYOFELBCo+wQgpYDwal/qJ1unBH73ywPztIJKXBXORRzAeNijsuxhk94h0TIMvfYg==",
+ "dependencies": {
+ "Azure.Core": "1.38.0",
+ "Microsoft.Identity.Client": "4.61.3",
+ "Microsoft.Identity.Client.Extensions.Msal": "4.61.3",
+ "System.Security.Cryptography.ProtectedData": "4.7.0"
+ }
+ },
+ "Azure.Messaging.EventGrid": {
+ "type": "Transitive",
+ "resolved": "5.0.0",
+ "contentHash": "/lc0X9Na9v8mb6cY8vRIaMuQEcerHK8msmwmc3nhkY9QX4x4R8w+sDxnHM0eXezzzmEumkfUPnDWIKCEzvsl9A==",
+ "dependencies": {
+ "Azure.Core": "1.46.2",
+ "Azure.Messaging.EventGrid.SystemEvents": "1.0.0"
+ }
+ },
+ "Azure.Messaging.EventGrid.SystemEvents": {
+ "type": "Transitive",
+ "resolved": "1.0.0",
+ "contentHash": "sGAZL0Kw3ErcPPdN3+OcMM+fTCeyGtP/No0+D7PP4tCI5VqKB2DxOlu/tdF4UYuk6YXE3XAZ/yHDjmzEaqr59g==",
+ "dependencies": {
+ "Azure.Core": "1.46.2"
+ }
+ },
+ "Azure.Messaging.ServiceBus": {
+ "type": "Transitive",
+ "resolved": "7.20.1",
+ "contentHash": "DxCkedWPQuiXrIyFcriOhsQcZmDZW+j9d55Ev4nnK3yjMUFjlVe4Hj37fuZTJlNhC3P+7EumqBTt33R6DfOxGA==",
+ "dependencies": {
+ "Azure.Core": "1.46.2",
+ "Azure.Core.Amqp": "1.3.1",
+ "Microsoft.Azure.Amqp": "2.7.0"
+ }
+ },
+ "Azure.Storage.Blobs": {
+ "type": "Transitive",
+ "resolved": "12.26.0",
+ "contentHash": "EBRSHmI0eNzdufcIS1Rf7Ez9M8V1Jl7pMV4UWDERDMCv513KtAVsgz2ez2FQP9Qnwg7uEQrP+Uc7vBtumlr7sQ==",
+ "dependencies": {
+ "Azure.Core": "1.47.3",
+ "Azure.Storage.Common": "12.25.0"
+ }
+ },
+ "Azure.Storage.Blobs.Batch": {
+ "type": "Transitive",
+ "resolved": "12.23.0",
+ "contentHash": "1Cj2/OEPoNpcwjQZ/vtng4ImrwuDlOZhYd3mKCxQXzUe50dl0lM5AWX8KE8GGKd5pLuRKYMNmn3mRvWpv/Me+A==",
+ "dependencies": {
+ "Azure.Core": "1.47.3",
+ "Azure.Storage.Blobs": "12.26.0",
+ "Azure.Storage.Common": "12.25.0"
+ }
+ },
+ "Azure.Storage.Common": {
+ "type": "Transitive",
+ "resolved": "12.25.0",
+ "contentHash": "MHGWp4aLHRo0BdLj25U2qYdYK//Zz21k4bs3SVyNQEmJbBl3qZ8GuOmTSXJ+Zad93HnFXfvD8kyMr0gjA8Ftpw==",
+ "dependencies": {
+ "Azure.Core": "1.47.3",
+ "System.IO.Hashing": "8.0.0"
+ }
+ },
+ "Azure.Storage.Queues": {
+ "type": "Transitive",
+ "resolved": "12.24.0",
+ "contentHash": "YSR051EMu421JZNCOyOB2JpVyA4bSW8CnbTYmYlwxsYIUJuwiMy2toSXIoq9RKG9PuBtnT5dS9M6QCYNGaswAw==",
+ "dependencies": {
+ "Azure.Core": "1.47.3",
+ "Azure.Storage.Common": "12.25.0"
+ }
+ },
+ "BitPay.Light": {
+ "type": "Transitive",
+ "resolved": "1.0.1907",
+ "contentHash": "QTTIgXakHrRNQPxNyH7bZ7frm0bI8N6gRDtiqVyKG/QYQ+KfjN70xt0zQ0kO0zf8UBaKuwcV5B7vvpXtzR9ijg==",
+ "dependencies": {
+ "Newtonsoft.Json": "12.0.2"
+ }
+ },
+ "Bogus": {
+ "type": "Transitive",
+ "resolved": "35.6.5",
+ "contentHash": "2FGZn+aAVHjmCgClgmGkTDBVZk0zkLvAKGaxEf5JL6b3i9JbHTE4wnuY4vHCuzlCmJdU6VZjgDfHwmYkQF8VAA=="
+ },
+ "BouncyCastle.Cryptography": {
+ "type": "Transitive",
+ "resolved": "2.6.2",
+ "contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
+ },
+ "Braintree": {
+ "type": "Transitive",
+ "resolved": "5.36.0",
+ "contentHash": "K43RjhEU5qXoYYxo0C0o54msQxbdRjVP+hDMZwSXsei4fLBHA2xwS1NCooZ9girCQNjoWOM8bygkHTVGgN+sag==",
+ "dependencies": {
+ "Newtonsoft.Json": "13.0.1",
+ "System.Xml.XPath.XmlDocument": "4.3.0"
+ }
+ },
+ "Castle.Core": {
+ "type": "Transitive",
+ "resolved": "5.1.1",
+ "contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==",
+ "dependencies": {
+ "System.Diagnostics.EventLog": "6.0.0"
+ }
+ },
+ "CsvHelper": {
+ "type": "Transitive",
+ "resolved": "33.1.0",
+ "contentHash": "kqfTOZGrn7NarNeXgjh86JcpTHUoeQDMB8t9NVa/ZtlSYiV1rxfRnQ49WaJsob4AiGrbK0XDzpyKkBwai4F8eg=="
+ },
+ "Dapper": {
+ "type": "Transitive",
+ "resolved": "2.1.66",
+ "contentHash": "/q77jUgDOS+bzkmk3Vy9SiWMaetTw+NOoPAV0xPBsGVAyljd5S6P+4RUW7R3ZUGGr9lDRyPKgAMj2UAOwvqZYw=="
+ },
+ "dbup-core": {
+ "type": "Transitive",
+ "resolved": "6.1.1",
+ "contentHash": "kgpuyJVEFJHoIj/slnc994Go88aoeZqNDfGHDBr4sh7CsEWwJhOTCt/FJqO4ziUImL5L0NEY0kxxOiNgPKI2Fw==",
+ "dependencies": {
+ "Microsoft.Extensions.Logging.Abstractions": "8.0.0"
+ }
+ },
+ "dbup-sqlserver": {
+ "type": "Transitive",
+ "resolved": "7.2.0",
+ "contentHash": "1xhdu2ZoQEi2nNrirBfkkfn+AbHWQvy8CGilb+5dIjghFJrsMKFM17DiI5Nz+ofWg9N1lqz5WorujzuGvc/+fQ==",
+ "dependencies": {
+ "Microsoft.Data.SqlClient": "6.1.4",
+ "dbup-core": "6.1.1"
+ }
+ },
+ "DnsClient": {
+ "type": "Transitive",
+ "resolved": "1.8.0",
+ "contentHash": "RRwtaCXkXWsx0mmsReGDqCbRLtItfUbkRJlet1FpdciVhyMGKcPd57T1+8Jki9ojHlq9fntVhXQroOOgRak8DQ=="
+ },
+ "Duende.IdentityModel": {
+ "type": "Transitive",
+ "resolved": "8.0.0",
+ "contentHash": "8i+Tv4c38LgwoTRKbD0+MqtnNNDSVA83G6JkjGHgC4/7jH0nxZBP0RBhH8xTsvNQ5Pv9zrg+TR8rmtWK9HDOPg=="
+ },
+ "Duende.IdentityServer": {
+ "type": "Transitive",
+ "resolved": "7.4.6",
+ "contentHash": "Zvri5e+SrOWLz0wmJry0ZaU8gVygv966jzP/CbMSCtV0K/nqK7abL9gQr9aKKmjt5DsONauUMT2E0cK/GbXJAg==",
+ "dependencies": {
+ "Duende.IdentityServer.Storage": "7.4.6",
+ "Microsoft.AspNetCore.Authentication.OpenIdConnect": "10.0.0"
+ }
+ },
+ "Duende.IdentityServer.Storage": {
+ "type": "Transitive",
+ "resolved": "7.4.6",
+ "contentHash": "qPNsoj5H1TaT5gYptA/Z5LZE/UT4PFsgDen8K1DLj4W9O8i1PuEfFiba9CbmwLPs/TUS2xikCbxfiUgBUqn8GQ==",
+ "dependencies": {
+ "Duende.IdentityModel": "8.0.0",
+ "Microsoft.AspNetCore.DataProtection.Abstractions": "10.0.0"
+ }
+ },
+ "DuoUniversal": {
+ "type": "Transitive",
+ "resolved": "1.3.1",
+ "contentHash": "BZUJplORCBO1PVDFT5v7HDYAlpgHAkay5N9vzRpJ/sBm+GU44pxNlo7v95Ym0tvUeKy0WWWv/iE4FjErYoZUHQ==",
+ "dependencies": {
+ "Microsoft.IdentityModel.JsonWebTokens": "6.34.0"
+ }
+ },
+ "Fare": {
+ "type": "Transitive",
+ "resolved": "2.1.1",
+ "contentHash": "HaI8puqA66YU7/9cK4Sgbs1taUTP1Ssa4QT2PIzqJ7GvAbN1QgkjbRsjH+FSbMh1MJdvS0CIwQNLtFT+KF6KpA==",
+ "dependencies": {
+ "NETStandard.Library": "1.6.1"
+ }
+ },
+ "Fido2": {
+ "type": "Transitive",
+ "resolved": "3.0.1",
+ "contentHash": "S0Bz1vfcKlO4Jase3AWp5XnQ746psf4oGx5kL+D2A10j1SsjoAOAIIpanSwfi0cEepDHgk1bClcOKY5TjOzGdA==",
+ "dependencies": {
+ "Fido2.Models": "3.0.1",
+ "Microsoft.Extensions.Http": "6.0.0",
+ "NSec.Cryptography": "22.4.0",
+ "System.Formats.Cbor": "6.0.0",
+ "System.IdentityModel.Tokens.Jwt": "6.17.0"
+ }
+ },
+ "Fido2.AspNet": {
+ "type": "Transitive",
+ "resolved": "3.0.1",
+ "contentHash": "5n5shEXD7RFUyTesjUHGDjkpgES7j4KotQo1GwUcS08k+fx+1tl/zCFHJ9RFDuUwO+S681ZILT2PyA67IPYpaA==",
+ "dependencies": {
+ "Fido2": "3.0.1",
+ "Fido2.Models": "3.0.1"
+ }
+ },
+ "Fido2.Models": {
+ "type": "Transitive",
+ "resolved": "3.0.1",
+ "contentHash": "mgjcuGETuYSCUEaZG+jQeeuuEMkDLc4GDJHBvKDdOz6oSOWp5adPdWP4btZx7Pi+9fu4szN3JIjJmby67MaILw=="
+ },
+ "Handlebars.Net": {
+ "type": "Transitive",
+ "resolved": "2.1.6",
+ "contentHash": "WsYWCEXsIM6hEOSOSRHtIYLjC8BnbT5MVmqhNKRqUI7qiv0t8x3nJiBTEv0ZZfvUAMAFnadGIzSsS/U2anVG1Q=="
+ },
+ "Kralizek.AutoFixture.Extensions.MockHttp": {
+ "type": "Transitive",
+ "resolved": "2.2.1",
+ "contentHash": "yNpYOT8k6L9PVS2YPoAe72IjILqGfPixKDzPsAFMz2aVyrmgGjirqORQa+bQNe+Qs5ytB+p41uzy4F9mjUuP9w==",
+ "dependencies": {
+ "AutoFixture": "4.18.1",
+ "RichardSzalay.MockHttp": "7.0.0"
+ }
+ },
+ "LaunchDarkly.Cache": {
+ "type": "Transitive",
+ "resolved": "1.0.2",
+ "contentHash": "0bEnUVFVeW1TTDXb/bW6kS3FLQTLeGtw7Xh8yt6WNO56utVmtgcrMLvcnF6yeTn+N4FXrKfW09KkLNmK8YYQvw=="
+ },
+ "LaunchDarkly.CommonSdk": {
+ "type": "Transitive",
+ "resolved": "7.1.1",
+ "contentHash": "J557Na/XeYV8JjgWbGRMzi5eOgXrEMqcuPBtkZP/y7EgFLiWgvaPBGDm+hq766Kp0Kxs2MaZaNTQn4Fog7Hebw==",
+ "dependencies": {
+ "LaunchDarkly.Logging": "2.0.0"
+ }
+ },
+ "LaunchDarkly.EventSource": {
+ "type": "Transitive",
+ "resolved": "5.3.0",
+ "contentHash": "i++YvdrzvTc1tOxfVreU2yjo/E+iTFcVtwiB4PZ/3uTouNdhABLbJfz1jmGN3jmnvJKWhsMeEZLZM0dzkI+PXQ==",
+ "dependencies": {
+ "LaunchDarkly.Logging": "[2.0.0, 3.0.0)"
+ }
+ },
+ "LaunchDarkly.InternalSdk": {
+ "type": "Transitive",
+ "resolved": "3.6.0",
+ "contentHash": "Drf1rL+sZ/4UZDUovDLgRN9qQPf8J4QTQjJR2A8nCuxkFU7QjVFXqqW9+KL8RfX3DmJkhpgC3QVA1B9B6xEYJQ==",
+ "dependencies": {
+ "LaunchDarkly.CommonSdk": "[7.1.1, 8.0.0)",
+ "LaunchDarkly.Logging": "[2.0.0, 3.0.0)"
+ }
+ },
+ "LaunchDarkly.Logging": {
+ "type": "Transitive",
+ "resolved": "2.0.0",
+ "contentHash": "lsLKNqAZ7HIlkdTIrf4FetfRA1SUDE3WlaZQn79aSVkLjYWEhUhkDDK7hORGh4JoA3V2gXN+cIvJQax2uR/ijA==",
+ "dependencies": {
+ "Microsoft.Extensions.Logging.Abstractions": "6.0.0"
+ }
+ },
+ "LaunchDarkly.ServerSdk": {
+ "type": "Transitive",
+ "resolved": "8.11.0",
+ "contentHash": "MMXfyGMDtul9UhEleHXdGNe2amLY1gjTZ9K2R18XoZp5OOlSGu4CmBqE4MXMsXhLYoEtYg2GIibmoTSPaU2Y+A==",
+ "dependencies": {
+ "LaunchDarkly.Cache": "1.0.2",
+ "LaunchDarkly.CommonSdk": "7.1.1",
+ "LaunchDarkly.EventSource": "5.3.0",
+ "LaunchDarkly.InternalSdk": "3.6.0",
+ "LaunchDarkly.Logging": "2.0.0"
+ }
+ },
+ "libsodium": {
+ "type": "Transitive",
+ "resolved": "1.0.18.2",
+ "contentHash": "flArHoVdscSzyV8ZdPV+bqqY2TTFlaN+xZf/vIqsmHI51KVcD/mOdUPaK3n/k/wGKz8dppiktXUqSmf3AXFgig=="
+ },
+ "linq2db": {
+ "type": "Transitive",
+ "resolved": "5.4.1",
+ "contentHash": "qyH32MbFK6T55KsEcQYTbPFfkOa1Mo65lY/Zo8SFVMy0pwkQBCTnA/RUxyG5+l3D/mgfPz85PH3upDrtklSMrw=="
+ },
+ "linq2db.EntityFrameworkCore": {
+ "type": "Transitive",
+ "resolved": "8.1.0",
+ "contentHash": "wEUTdkWsrtwlE3aAb4qmxNkjrZOVp39KBM+wPvEnTNXoSym6Po3u9/PWRWAsbJAGjoljv5604ACcCOp/yMJ5XQ==",
+ "dependencies": {
+ "Microsoft.EntityFrameworkCore.Relational": "8.0.0",
+ "linq2db": "5.4.0"
+ }
+ },
+ "MailKit": {
+ "type": "Transitive",
+ "resolved": "4.16.0",
+ "contentHash": "trJ82DOpAmo8i1jO1vNE+dGn4mPRyeYfy4swRcAGgMJhPoI1Kohf4OFJJf0+YIj4iUxgxPn8W+ht7e7KiYzSjg==",
+ "dependencies": {
+ "MimeKit": "4.16.0"
+ }
+ },
+ "Markdig": {
+ "type": "Transitive",
+ "resolved": "1.1.0",
+ "contentHash": "ivaowI69dGxiyaKLy6+qo9Xm4DHwXDKB3orGIFOvA8k+eUBAV5UaW8GPIMc3h5jl7gZalyecR3x2t+nuLYdmFg=="
+ },
+ "Microsoft.AspNetCore.Authentication.JwtBearer": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "oGnE+X/SN6jdqao9WOkOIfyZ5+a0AtluJWy1Mxndq+kcWG6sx5k6l6tucu8/wJ7o9fHfLgVCzm/c4v/KVgVk6w==",
+ "dependencies": {
+ "Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.0.1"
+ }
+ },
+ "Microsoft.AspNetCore.Authentication.OpenIdConnect": {
+ "type": "Transitive",
+ "resolved": "10.0.0",
+ "contentHash": "6ATONu+5A2oh/vzmoFhf3cuQcclMaWGHrb1kvjVsYtml+gzuWD48MmbsItM4xAUQkJZ2t8XFmbGp8pZLPxKneA==",
+ "dependencies": {
+ "Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.0.1"
+ }
+ },
+ "Microsoft.AspNetCore.Cryptography.Internal": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "R0TKX26vPlw4kfyaLECwxn5GUIUAv6B+5s8kiEku9cl9VVCrDQDslPuhUUhN6oI/TvLj1lMFkz6AhHIpbPC3Lg=="
+ },
+ "Microsoft.AspNetCore.Cryptography.KeyDerivation": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "eKKLdOmdyr8TrzvD9eKdcvh8OlfomZiN6FwZNlK+F8fihZJBRgNVReqt3evYvZMHK3N/0Ui6P14cubllt1cFUg==",
+ "dependencies": {
+ "Microsoft.AspNetCore.Cryptography.Internal": "10.0.8"
+ }
+ },
+ "Microsoft.AspNetCore.DataProtection": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "Rlbrr3XSGB4dwnUY/pA70TpVyrQhelDAUkIiGfJ2Tm32mscTYxrRnk9Ooy1rRhGZ1g7rliJPuNzhyMMKmRH5pw==",
+ "dependencies": {
+ "Microsoft.AspNetCore.Cryptography.Internal": "10.0.8",
+ "Microsoft.AspNetCore.DataProtection.Abstractions": "10.0.8",
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8",
+ "Microsoft.Extensions.Hosting.Abstractions": "10.0.8",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.8",
+ "Microsoft.Extensions.Options": "10.0.8",
+ "System.Security.Cryptography.Xml": "10.0.8"
+ }
+ },
+ "Microsoft.AspNetCore.DataProtection.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "NYxEmhe2tDPwwGgl0kNraUOJdOqaIYJpnZ1Lf7OlqWRf3aLagniVxMl8JSVmy0jiRtElsPYq8lTvLlbvRSwCLA=="
+ },
+ "Microsoft.AspNetCore.Mvc.Testing": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "C9kMpUciPgx7ObqoO6W+eXEf3zHFWb7XpQgFJBzdO8GsmmVYrgcErTLMuki6e3EihycGpHbcJECYHDgM7XRMkg==",
+ "dependencies": {
+ "Microsoft.AspNetCore.TestHost": "10.0.8",
+ "Microsoft.Extensions.DependencyModel": "10.0.8",
+ "Microsoft.Extensions.Hosting": "10.0.8"
+ }
+ },
+ "Microsoft.AspNetCore.TestHost": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "HRH/XAke90wkHv9ykCsrvpVqvKOUt53jQzvHHIXrPIPZWAjyPq6B5/InCmPYWvme+WKMXD10rplMAitzNMtC3w=="
+ },
+ "Microsoft.Azure.Amqp": {
+ "type": "Transitive",
+ "resolved": "2.7.0",
+ "contentHash": "gm/AEakujttMzrDhZ5QpRz3fICVkYDn/oDG9SmxDP+J7R8JDBXYU9WWG7hr6wQy40mY+wjUF0yUGXDPRDRNJwQ=="
+ },
+ "Microsoft.Azure.Cosmos": {
+ "type": "Transitive",
+ "resolved": "3.52.0",
+ "contentHash": "NEjNpaO19gvJrXowqHFcYPSpro5+TNjHO/JpU4VXP37by2aU2RVnmgpZsWL1GUl5wCPZ4VIOUsP1lrHpfW8ADQ==",
+ "dependencies": {
+ "Azure.Core": "1.44.1",
+ "Microsoft.Bcl.AsyncInterfaces": "6.0.0",
+ "Microsoft.Bcl.HashCode": "1.1.0",
+ "System.Configuration.ConfigurationManager": "6.0.0"
+ }
+ },
+ "Microsoft.Azure.NotificationHubs": {
+ "type": "Transitive",
+ "resolved": "4.2.0",
+ "contentHash": "LOCxFB/sB1frfuXjecdDRDKEHkH+I1qKRatS5NyWIgYhpKhIcAlPNIJajcwLgQBShckdc3hMG9E+75CnL3qDhQ==",
+ "dependencies": {
+ "Microsoft.Extensions.Caching.Memory": "6.0.1",
+ "Newtonsoft.Json": "13.0.1"
+ }
+ },
+ "Microsoft.Bcl.AsyncInterfaces": {
+ "type": "Transitive",
+ "resolved": "8.0.0",
+ "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw=="
+ },
+ "Microsoft.Bcl.Cryptography": {
+ "type": "Transitive",
+ "resolved": "9.0.13",
+ "contentHash": "5T+bH3Lb1nEe8Hf/ixMxLmhlrx5wRi53wv7OhVwG2F1ZviW1ejFRS1NHur3uqPpJRGtkQwUchtY6zhVK2R+v+w=="
+ },
+ "Microsoft.Bcl.HashCode": {
+ "type": "Transitive",
+ "resolved": "1.1.0",
+ "contentHash": "J2G1k+u5unBV+aYcwxo94ip16Rkp65pgWFb0R6zwJipzWNMgvqlWeuI7/+R+e8bob66LnSG+llLJ+z8wI94cHg=="
+ },
+ "Microsoft.Bot.Builder": {
+ "type": "Transitive",
+ "resolved": "4.23.0",
+ "contentHash": "bLTrp/tfSNWDAIJ90TqyZ8JtpjCvNO7kgvhW0R5eSu72tAifwEIi2uxhFS+c8alsa2DM4EW3JSemoDgn0r6Hog==",
+ "dependencies": {
+ "Microsoft.Bot.Connector": "4.23.0",
+ "Microsoft.Bot.Connector.Streaming": "4.23.0",
+ "Microsoft.Bot.Streaming": "4.23.0",
+ "Microsoft.Extensions.DependencyInjection": "8.0.0",
+ "Microsoft.Extensions.Logging": "8.0.0"
+ }
+ },
+ "Microsoft.Bot.Builder.Integration.AspNet.Core": {
+ "type": "Transitive",
+ "resolved": "4.23.0",
+ "contentHash": "p6xghjJfg3Vh/q2NSd77TtuvCykuEakzMaELHctf3Cw4eTILsXWIgEJ0QxyelMnJGJjgfBwFS9ZeC2hWUFYzBA==",
+ "dependencies": {
+ "Microsoft.Bot.Builder": "4.23.0",
+ "Microsoft.Bot.Configuration": "4.23.0",
+ "Microsoft.Bot.Connector.Streaming": "4.23.0",
+ "Microsoft.Bot.Streaming": "4.23.0",
+ "Microsoft.Extensions.Configuration.Binder": "8.0.2",
+ "Newtonsoft.Json": "13.0.3"
+ }
+ },
+ "Microsoft.Bot.Configuration": {
+ "type": "Transitive",
+ "resolved": "4.23.0",
+ "contentHash": "yCzxNU5QAEQ6zy7VBNuz3GwOY8OZcDkNYOmPw/QuVzViozxuJI200BMl+a5jhY9Nd7j6bGxO7Y3mmHy4Tu7Teg==",
+ "dependencies": {
+ "Newtonsoft.Json": "13.0.3"
+ }
+ },
+ "Microsoft.Bot.Connector": {
+ "type": "Transitive",
+ "resolved": "4.23.0",
+ "contentHash": "/vgAQ8LonwAnyu6CzYYElU4k65k+9V2ncwztpZtBM+IuwkhDe3iAO2ycObqcyhMMWYUV81IOb0JZYcabjiZ4NQ==",
+ "dependencies": {
+ "Microsoft.Bot.Schema": "4.23.0",
+ "Microsoft.Extensions.Http": "8.0.0",
+ "Microsoft.Extensions.Logging": "8.0.0",
+ "Microsoft.Identity.Client": "4.66.1",
+ "Microsoft.Identity.Web.Certificateless": "3.3.0",
+ "Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.1.2",
+ "Microsoft.Rest.ClientRuntime": "2.3.24",
+ "Newtonsoft.Json": "13.0.3"
+ }
+ },
+ "Microsoft.Bot.Connector.Streaming": {
+ "type": "Transitive",
+ "resolved": "4.23.0",
+ "contentHash": "Yz6PcySgtje88IGEJXj6agzya48pBL34/A5Zs3/xqmHvEQlI2ypMNc3OBOo2TRGxykklCSwc60PN2k95d4pbFw==",
+ "dependencies": {
+ "Microsoft.Bot.Schema": "4.23.0",
+ "Microsoft.Bot.Streaming": "4.23.0",
+ "Microsoft.Extensions.Logging": "8.0.0",
+ "Newtonsoft.Json": "13.0.3"
+ }
+ },
+ "Microsoft.Bot.Schema": {
+ "type": "Transitive",
+ "resolved": "4.23.0",
+ "contentHash": "HZeEXg/PuniNRIUU8ioSx/LrOXcbTZCZ1hUIqDdc6akWwhjrC2sTv7TaBpD0FlY4hDyUvj3GLyurPI/YChGPzA==",
+ "dependencies": {
+ "AdaptiveCards": "3.1.0",
+ "Newtonsoft.Json": "13.0.3"
+ }
+ },
+ "Microsoft.Bot.Streaming": {
+ "type": "Transitive",
+ "resolved": "4.23.0",
+ "contentHash": "gYudjsFAVjwZBRU5irJh7sdsD7gNRFfZEUwWTqkNp1eGaR1t+4sJY+YyqYL3PyCMSgLrKE46bt/JWuDi3CjRfA==",
+ "dependencies": {
+ "Microsoft.Extensions.Logging": "8.0.0",
+ "Newtonsoft.Json": "13.0.3"
+ }
+ },
+ "Microsoft.CodeCoverage": {
+ "type": "Transitive",
+ "resolved": "18.0.1",
+ "contentHash": "O+utSr97NAJowIQT/OVp3Lh9QgW/wALVTP4RG1m2AfFP4IyJmJz0ZBmFJUsRQiAPgq6IRC0t8AAzsiPIsaUDEA=="
+ },
+ "Microsoft.Data.SqlClient": {
+ "type": "Transitive",
+ "resolved": "7.0.0",
+ "contentHash": "/oolEwtHuDtpLKU8OItOTTxVJalgPtIkcNBwzXJ3YGyrkOAvLYqMtin9Z1jxqryJpds3PjuZBF5iKIYVVYVSvQ==",
+ "dependencies": {
+ "Microsoft.Bcl.Cryptography": "9.0.13",
+ "Microsoft.Data.SqlClient.Extensions.Abstractions": "1.0.0",
+ "Microsoft.Data.SqlClient.Internal.Logging": "1.0.0",
+ "Microsoft.Data.SqlClient.SNI.runtime": "6.0.2",
+ "Microsoft.Extensions.Caching.Memory": "9.0.13",
+ "Microsoft.IdentityModel.JsonWebTokens": "8.16.0",
+ "Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.16.0",
+ "Microsoft.SqlServer.Server": "1.0.0",
+ "System.Configuration.ConfigurationManager": "9.0.13",
+ "System.Security.Cryptography.Pkcs": "9.0.13"
+ }
+ },
+ "Microsoft.Data.SqlClient.Extensions.Abstractions": {
+ "type": "Transitive",
+ "resolved": "1.0.0",
+ "contentHash": "rlnxc0KfwDSbE8ZHntFnl8SCgOa9QtJZblMv2zXLhRwl1Je7fsdsVzxSjzzC4JMsfAK+jXJWyezRB8SxUY4BdA==",
+ "dependencies": {
+ "Microsoft.Data.SqlClient.Internal.Logging": "1.0.0"
+ }
+ },
+ "Microsoft.Data.SqlClient.Internal.Logging": {
+ "type": "Transitive",
+ "resolved": "1.0.0",
+ "contentHash": "Kue/7CF8KNT9zozfr30C94dMZVZml3atqWZvQemSXvTau76tRdypzeKiBKXadqgbOME0UiQIyVTNo5WxCRNVNg=="
+ },
+ "Microsoft.Data.SqlClient.SNI.runtime": {
+ "type": "Transitive",
+ "resolved": "6.0.2",
+ "contentHash": "f+pRODTWX7Y67jXO3T5S2dIPZ9qMJNySjlZT/TKmWVNWe19N8jcWmHaqHnnchaq3gxEKv1SWVY5EFzOD06l41w=="
+ },
+ "Microsoft.Data.Sqlite.Core": {
+ "type": "Transitive",
+ "resolved": "8.0.8",
+ "contentHash": "qHInO2EvOcPhjgboP0TGnXM7rASdvWXrw6jAH8Yuz5YP82VTje7d/NKiX1i+dVbE3+G3JuW1kqNVB8yLvsqgYA==",
+ "dependencies": {
+ "SQLitePCLRaw.core": "2.1.6"
+ }
+ },
+ "Microsoft.EntityFrameworkCore": {
+ "type": "Transitive",
+ "resolved": "8.0.8",
+ "contentHash": "iK+jrJzkfbIxutB7or808BPmJtjUEi5O+eSM7cLDwsyde6+3iOujCSfWnrHrLxY3u+EQrJD+aD8DJ6ogPA2Rtw==",
+ "dependencies": {
+ "Microsoft.EntityFrameworkCore.Abstractions": "8.0.8",
+ "Microsoft.EntityFrameworkCore.Analyzers": "8.0.8",
+ "Microsoft.Extensions.Caching.Memory": "8.0.0",
+ "Microsoft.Extensions.Logging": "8.0.0"
+ }
+ },
+ "Microsoft.EntityFrameworkCore.Abstractions": {
+ "type": "Transitive",
+ "resolved": "8.0.8",
+ "contentHash": "9mMQkZsfL1c2iifBD8MWRmwy59rvsVtR9NOezJj7+g1j4P7g49MJHd8k8faC/v7d5KuHkQ6KOQiSItvoRt9PXA=="
+ },
+ "Microsoft.EntityFrameworkCore.Analyzers": {
+ "type": "Transitive",
+ "resolved": "8.0.8",
+ "contentHash": "OlAXMU+VQgLz5y5/SBkLvAa9VeiR3dlJqgIebEEH2M2NGA3evm68/Tv7SLWmSxwnEAtA3nmDEZF2pacK6eXh4Q=="
+ },
+ "Microsoft.EntityFrameworkCore.Relational": {
+ "type": "Transitive",
+ "resolved": "8.0.8",
+ "contentHash": "3WnrwdXxKg4L98cDx0lNEEau8U2lsfuBJCs0Yzht+5XVTmahboM7MukKfQHAzVsHUPszm6ci929S7Qas0WfVHA==",
+ "dependencies": {
+ "Microsoft.EntityFrameworkCore": "8.0.8",
+ "Microsoft.Extensions.Configuration.Abstractions": "8.0.0"
+ }
+ },
+ "Microsoft.EntityFrameworkCore.Sqlite": {
+ "type": "Transitive",
+ "resolved": "8.0.8",
+ "contentHash": "IDB7Xs16hN/3VkWFCCa4r3fqoJxMVezwq418gr8dBkRBO0pxH+BX/Kjk/U3PYXDvzVLkXqUgJsHv1XoFrJbZPQ==",
+ "dependencies": {
+ "Microsoft.EntityFrameworkCore.Sqlite.Core": "8.0.8",
+ "SQLitePCLRaw.bundle_e_sqlite3": "2.1.6"
+ }
+ },
+ "Microsoft.EntityFrameworkCore.Sqlite.Core": {
+ "type": "Transitive",
+ "resolved": "8.0.8",
+ "contentHash": "w5k/ENj3+BPbmggqh83RRuPhhKcJmW7CmdJuGwdX1eFrmptJwnzKiHfQCPkJAu9df16PSs5YFeWrDgepfqnltA==",
+ "dependencies": {
+ "Microsoft.Data.Sqlite.Core": "8.0.8",
+ "Microsoft.EntityFrameworkCore.Relational": "8.0.8",
+ "Microsoft.Extensions.DependencyModel": "8.0.1"
+ }
+ },
+ "Microsoft.EntityFrameworkCore.SqlServer": {
+ "type": "Transitive",
+ "resolved": "8.0.8",
+ "contentHash": "A2F52W+hnGqvprx37HcAnYnJv4QoFFdc9cxd/QGNSd1vCu1I0eAEKRd0r9KS3E5I5RRj/m9XJfYCyTdy1cdn5Q==",
+ "dependencies": {
+ "Microsoft.Data.SqlClient": "5.1.5",
+ "Microsoft.EntityFrameworkCore.Relational": "8.0.8"
+ }
+ },
+ "Microsoft.Extensions.ApiDescription.Server": {
+ "type": "Transitive",
+ "resolved": "10.0.0",
+ "contentHash": "NCWCGiwRwje8773yzPQhvucYnnfeR+ZoB1VRIrIMp4uaeUNw7jvEPHij3HIbwCDuNCrNcphA00KSAR9yD9qmbg=="
+ },
+ "Microsoft.Extensions.Caching.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "EoK2TwVR1daxmfXUPnvIYZSk5XQjHe45sGekox4kvMt88KQZQhDVzYW5Na5+oNwTuRpE48hipyGJg12F1Tm70w==",
+ "dependencies": {
+ "Microsoft.Extensions.Primitives": "10.0.8"
+ }
+ },
+ "Microsoft.Extensions.Caching.Cosmos": {
+ "type": "Transitive",
+ "resolved": "1.8.0",
+ "contentHash": "8UI41/U5yla1z48klbtRdXxxloAChRzhAm592bitBNzTlXBq87zeO6Lbdvuh5d6oLNCU0I01kw6ovTD9Z6y05g==",
+ "dependencies": {
+ "Microsoft.Azure.Cosmos": "3.47.0",
+ "Microsoft.Extensions.Caching.Abstractions": "6.0.0",
+ "Microsoft.Extensions.Options": "6.0.0",
+ "Newtonsoft.Json": "13.0.3"
+ }
+ },
+ "Microsoft.Extensions.Caching.Memory": {
+ "type": "Transitive",
+ "resolved": "9.0.13",
+ "contentHash": "OdQmN8LYcUEu20Fxii9mk68nHJGL+JPXF3w0+hxenf0oDDdDBA+ZV/S92FmIgAWAElowIiFA/g0x+8YB1g80Hg==",
+ "dependencies": {
+ "Microsoft.Extensions.Caching.Abstractions": "9.0.13",
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.13",
+ "Microsoft.Extensions.Logging.Abstractions": "9.0.13",
+ "Microsoft.Extensions.Options": "9.0.13",
+ "Microsoft.Extensions.Primitives": "9.0.13"
+ }
+ },
+ "Microsoft.Extensions.Caching.SqlServer": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "GSP1UIw/VFiLOWBOCQYwLHsLnkqqqqEC9sc8IqCXpbSkwz03EZ9u4jcFORTE4TJ/gkKXKP6L3AO+ZFZ5MFX4Gg==",
+ "dependencies": {
+ "Azure.Identity": "1.11.4",
+ "Microsoft.Data.SqlClient": "5.2.2",
+ "Microsoft.Extensions.Caching.Abstractions": "10.0.8",
+ "Microsoft.Extensions.Options": "10.0.8"
+ }
+ },
+ "Microsoft.Extensions.Caching.StackExchangeRedis": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "fle9ns3q2kk63vt/wHFtLw1U9kiEbM42vTk2Sar8VBjiGJFkXAgm0QEKFx15YMHkJIPRVdknWaovpvgGEgn10g==",
+ "dependencies": {
+ "Microsoft.Extensions.Caching.Abstractions": "10.0.8",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.8",
+ "Microsoft.Extensions.Options": "10.0.8",
+ "StackExchange.Redis": "2.7.27"
+ }
+ },
+ "Microsoft.Extensions.Compliance.Abstractions": {
+ "type": "Transitive",
+ "resolved": "9.10.0",
+ "contentHash": "Tgu40iIg2Kr8s+BoOhb8r8kQfcagwm1VnpnMZA9fd/sD8Hlj13cNpyCfLRrYEBP+VmfmaoficQvRNEUqH+F4mw==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.10",
+ "Microsoft.Extensions.ObjectPool": "9.0.10"
+ }
+ },
+ "Microsoft.Extensions.Configuration": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "ehZcoPbjzWzS4XFvuz7R3V55SmpdkyMqFURLH3yXaN9NtXd9tR6CGB7pd49HYtCkenl+G7ctXSFLhNI08xLfRg==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.8",
+ "Microsoft.Extensions.Primitives": "10.0.8"
+ }
+ },
+ "Microsoft.Extensions.Configuration.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "I63esIFbL3h5pSt7gXpXOlmcwDmYBUoYNEglKfDPFUqtYvSV84f2l28hO2lfVXsV0wdlplgAM7IVz16matapSg==",
+ "dependencies": {
+ "Microsoft.Extensions.Primitives": "10.0.8"
+ }
+ },
+ "Microsoft.Extensions.Configuration.Binder": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "R3NN1X+kVu14uoxLEW6sBSQyhogDSbaOQzILnCtuXxBN4hx22AgjWPwZX6v/suERFkEDgU1lk12AglHTrUxhlw==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "10.0.8",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.8"
+ }
+ },
+ "Microsoft.Extensions.Configuration.CommandLine": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "nQXq1a4MiInYh+0VF9fguxAl06q2ftmOyYQ+5e933s4rk57xjgkbTjUdFUySzjrcrvDeWsSqlZB+TE8+TbM2HA==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "10.0.8",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.8"
+ }
+ },
+ "Microsoft.Extensions.Configuration.EnvironmentVariables": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "bVGqctAfPGfTxJvNp8pMshtvpsUj6r6JkeiCNVIGVYO5gBxuxdN0Lbr25kEvE/zXdctkEc44g8HssnPgDnFGVA==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "10.0.8",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.8"
+ }
+ },
+ "Microsoft.Extensions.Configuration.FileExtensions": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "1g9mzuu8gIHkjYb0jLxOTQVl/QDG5nn0b0JzgT/gbgNKr6gXZzxOHRAsdYRc1eDApB7LdHR8uK5vQrNjIQdRrQ==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "10.0.8",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.8",
+ "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8",
+ "Microsoft.Extensions.FileProviders.Physical": "10.0.8",
+ "Microsoft.Extensions.Primitives": "10.0.8"
+ }
+ },
+ "Microsoft.Extensions.Configuration.Json": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "KLtAZ6A38s1pIfCO2ns6aG14NNGMYNZ4PBYfFK4M+R4A+xuSc6oklhqDcpHZxvDpyBWeFtR5C8iQBw2ng8tUHQ==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "10.0.8",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.8",
+ "Microsoft.Extensions.Configuration.FileExtensions": "10.0.8",
+ "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8"
+ }
+ },
+ "Microsoft.Extensions.Configuration.UserSecrets": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "6XTfFOnf27WY8kEeZkTZ4YNn0t+imgvdQ0YaAdR4vgURKATo9bCaVJ1KB71IOJAQtJP7Elb53VHlTNXg2CtSsA==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.8",
+ "Microsoft.Extensions.Configuration.Json": "10.0.8",
+ "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8",
+ "Microsoft.Extensions.FileProviders.Physical": "10.0.8"
+ }
+ },
+ "Microsoft.Extensions.DependencyInjection": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "daf62xHIrq8pnE709hgaZZN9tSam9TGGepWe1+bE6V3GEuVwJiMs6ib+38lfMCyAJAHiX0vapxBhsuMSV7U+cg==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8"
+ }
+ },
+ "Microsoft.Extensions.DependencyInjection.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A=="
+ },
+ "Microsoft.Extensions.DependencyModel": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "vLyZVpxmduO2jx+76ggqnsA3m81kwMY3NkWciNTj5E+Nvqb0VihqCvQP89QsGONWp0AJwMZG+u9GzaCjDdFGNw=="
+ },
+ "Microsoft.Extensions.Diagnostics": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "uduyw9d3Fi+sbredO5drA1S44AQS2FRNFyn72UmB2vmQIO1qaXprpp1U/2lYhYi8yFdVERfY9sy/pxw/qPOU9w==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "10.0.8",
+ "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.8",
+ "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.8"
+ }
+ },
+ "Microsoft.Extensions.Diagnostics.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "+f4C5g78QCGNyxzUfrTYsB7qYx06Zca0e88s3qFlea9/lQhgPImYdNprlgzl1uHhRU3fVHLfmbijayU2sJEZ6w==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8",
+ "Microsoft.Extensions.Options": "10.0.8"
+ }
+ },
+ "Microsoft.Extensions.Diagnostics.HealthChecks": {
+ "type": "Transitive",
+ "resolved": "8.0.0",
+ "contentHash": "P9SoBuVZhJPpALZmSq72aQEb9ryP67EdquaCZGXGrrcASTNHYdrUhnpgSwIipgM5oVC+dKpRXg5zxobmF9xr5g==",
+ "dependencies": {
+ "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "8.0.0",
+ "Microsoft.Extensions.Hosting.Abstractions": "8.0.0",
+ "Microsoft.Extensions.Logging.Abstractions": "8.0.0",
+ "Microsoft.Extensions.Options": "8.0.0"
+ }
+ },
+ "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": {
+ "type": "Transitive",
+ "resolved": "8.0.0",
+ "contentHash": "AT2qqos3IgI09ok36Qag9T8bb6kHJ3uT9Q5ki6CySybFsK6/9JbvQAgAHf1pVEjST0/N4JaFaCbm40R5edffwg=="
+ },
+ "Microsoft.Extensions.Diagnostics.Testing": {
+ "type": "Transitive",
+ "resolved": "9.10.0",
+ "contentHash": "p8XnKg4yZRRpORwm5VvoBZbHeEQUmJM6OMg4psJpClkyeVLP6sc+/bqR2rnfRUyP7yhIzJB/Tmulrg6mTt8dBw==",
+ "dependencies": {
+ "Microsoft.Extensions.Logging": "9.0.10",
+ "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.10",
+ "Microsoft.Extensions.Telemetry.Abstractions": "9.10.0"
+ }
+ },
+ "Microsoft.Extensions.FileProviders.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "U+oquaPxFdY8lYeEIWO/AD7jDIl9sPW6aVWMQRHU/pZ/SWpLcOrAj2fcLe1HwXl4sYw1ONI56K/eELT3xr4RRQ==",
+ "dependencies": {
+ "Microsoft.Extensions.Primitives": "10.0.8"
+ }
+ },
+ "Microsoft.Extensions.FileProviders.Physical": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "GkPvQe6IdidLu6Q3Lw6+B8NJpW8feW8czZ5mBKt5rXM/x8MvZfEp5WvAsjznzDGd23chIDrW0b2mmt+ScnEgiw==",
+ "dependencies": {
+ "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8",
+ "Microsoft.Extensions.FileSystemGlobbing": "10.0.8",
+ "Microsoft.Extensions.Primitives": "10.0.8"
+ }
+ },
+ "Microsoft.Extensions.FileSystemGlobbing": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "IUQet3SY51xIFcFZKtAB6a54/Zdxs7T3SQ84kJtOD6yeXfZgiOMksACWD5qtTmXGQGFH4QYGBOT0KIO8Uy/dJw=="
+ },
+ "Microsoft.Extensions.Hosting": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "VfEyM2BipThcSd0GG/FS2ZPCVCTiosVq2zLKEDsfeMIg78sOVZPEmS7CgWlb+dqTlgXvLSL4OG2q6sM4xRhHNg==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "10.0.8",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.8",
+ "Microsoft.Extensions.Configuration.Binder": "10.0.8",
+ "Microsoft.Extensions.Configuration.CommandLine": "10.0.8",
+ "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.8",
+ "Microsoft.Extensions.Configuration.FileExtensions": "10.0.8",
+ "Microsoft.Extensions.Configuration.Json": "10.0.8",
+ "Microsoft.Extensions.Configuration.UserSecrets": "10.0.8",
+ "Microsoft.Extensions.DependencyInjection": "10.0.8",
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8",
+ "Microsoft.Extensions.Diagnostics": "10.0.8",
+ "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8",
+ "Microsoft.Extensions.FileProviders.Physical": "10.0.8",
+ "Microsoft.Extensions.Hosting.Abstractions": "10.0.8",
+ "Microsoft.Extensions.Logging": "10.0.8",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.8",
+ "Microsoft.Extensions.Logging.Configuration": "10.0.8",
+ "Microsoft.Extensions.Logging.Console": "10.0.8",
+ "Microsoft.Extensions.Logging.Debug": "10.0.8",
+ "Microsoft.Extensions.Logging.EventLog": "10.0.8",
+ "Microsoft.Extensions.Logging.EventSource": "10.0.8",
+ "Microsoft.Extensions.Options": "10.0.8"
+ }
+ },
+ "Microsoft.Extensions.Hosting.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "MoOWFPT88/pDfmWpbU9PydKRX/rJFQkliowE/L9wbQcl94IicUphb5BFgepkWiDkYYxPnuEqjN4buzOGW4vJpQ==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.8",
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8",
+ "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.8",
+ "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.8"
+ }
+ },
+ "Microsoft.Extensions.Http": {
+ "type": "Transitive",
+ "resolved": "8.0.0",
+ "contentHash": "cWz4caHwvx0emoYe7NkHPxII/KkTI8R/LC9qdqJqnKv2poTJ4e2qqPGQqvRoQ5kaSA4FU5IV3qFAuLuOhoqULQ==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration.Abstractions": "8.0.0",
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
+ "Microsoft.Extensions.Diagnostics": "8.0.0",
+ "Microsoft.Extensions.Logging": "8.0.0",
+ "Microsoft.Extensions.Logging.Abstractions": "8.0.0",
+ "Microsoft.Extensions.Options": "8.0.0"
+ }
+ },
+ "Microsoft.Extensions.Identity.Core": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "ZOuH3nlDslon9a0kJSRBTWnHNCKoiOoaurc3H1F4D6xLT+4UDvBNAqlLkEFyQdcxZFyUvYdwgc1+D/EjsD+RXA==",
+ "dependencies": {
+ "Microsoft.AspNetCore.Cryptography.KeyDerivation": "10.0.8",
+ "Microsoft.Extensions.Diagnostics": "10.0.8",
+ "Microsoft.Extensions.Logging": "10.0.8",
+ "Microsoft.Extensions.Options": "10.0.8"
+ }
+ },
+ "Microsoft.Extensions.Identity.Stores": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "xVbg4qLWyjKSJVxtL56PQPlHu/URpWPKufhfOj61+tkCmNs6DIgnGxG8BAO/fAfacoBDDYg+p1zBjFzzj/EQog==",
+ "dependencies": {
+ "Microsoft.Extensions.Caching.Abstractions": "10.0.8",
+ "Microsoft.Extensions.Identity.Core": "10.0.8",
+ "Microsoft.Extensions.Logging": "10.0.8"
+ }
+ },
+ "Microsoft.Extensions.Logging": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "K60JhWC2hN/Gi7TP68tBxSzk5ACWOs7lkmPzsfA8Bcf/IXTajujt2ORMf9rSMk1bsng6Lv4Y3fuxp3bm1+15ug==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection": "10.0.8",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.8",
+ "Microsoft.Extensions.Options": "10.0.8"
+ }
+ },
+ "Microsoft.Extensions.Logging.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8"
+ }
+ },
+ "Microsoft.Extensions.Logging.Configuration": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "rxSLTO7xTbcC3DuEJHNEijBr8g14Jj62zQ+DeFu68bsoTYoU8jLcMhc1735PV21bESXsATlL5LsfaWH71FOWAg==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "10.0.8",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.8",
+ "Microsoft.Extensions.Configuration.Binder": "10.0.8",
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8",
+ "Microsoft.Extensions.Logging": "10.0.8",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.8",
+ "Microsoft.Extensions.Options": "10.0.8",
+ "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.8"
+ }
+ },
+ "Microsoft.Extensions.Logging.Console": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "6cv53sHsPnFS56PJw8X4GbNcjeX1KGyFJRxJWvxOgK63cnqeSB1k1eRwjUdkse0tBhwlH6qc9EOYDlan+CYTuw==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8",
+ "Microsoft.Extensions.Logging": "10.0.8",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.8",
+ "Microsoft.Extensions.Logging.Configuration": "10.0.8",
+ "Microsoft.Extensions.Options": "10.0.8"
+ }
+ },
+ "Microsoft.Extensions.Logging.Debug": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "4HW3M1lGHHDwEYcDZHRNptBQ48LCI2yW+XV4vuxdfQUqafTpVT8j9RqAsez08krZKhIiaArWu8iQq5uRKZ9Ffg==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8",
+ "Microsoft.Extensions.Logging": "10.0.8",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.8"
+ }
+ },
+ "Microsoft.Extensions.Logging.EventLog": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "kK/C3SLIoGrcZvddYQw4eMm6YaROiSYBO7YgUR5Hdv5l+GIjBmbvQK5cST2FqjeubiAOPqFEimBT2N/8wVI+3A==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8",
+ "Microsoft.Extensions.Logging": "10.0.8",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.8",
+ "Microsoft.Extensions.Options": "10.0.8",
+ "System.Diagnostics.EventLog": "10.0.8"
+ }
+ },
+ "Microsoft.Extensions.Logging.EventSource": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "HX2M0MgzwQM8jpLe3AYAEMd0YsUfOP5RgGrDuk+Ki9n7HSuMbvLm9TEV3qRI3Pg9aqxc56GfgK/KdMRBhfWwKw==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8",
+ "Microsoft.Extensions.Logging": "10.0.8",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.8",
+ "Microsoft.Extensions.Options": "10.0.8",
+ "Microsoft.Extensions.Primitives": "10.0.8"
+ }
+ },
+ "Microsoft.Extensions.ObjectPool": {
+ "type": "Transitive",
+ "resolved": "9.0.10",
+ "contentHash": "tw0jYoEdRp2AQMBYTkdCy0OKWcNaazaFQgo4KzdayTkX2N00g2hAacGd9mls4nBz6clP+87eeD0ucWyDrz+VKg=="
+ },
+ "Microsoft.Extensions.Options": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "VBD+131DpTNCNDfA4kIyKTiCySvJGNhwibdWBSdFRu7GMfXLXcXODkgA+KStKbbhzraLglZWUN4nXyHgW4JIRA==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8",
+ "Microsoft.Extensions.Primitives": "10.0.8"
+ }
+ },
+ "Microsoft.Extensions.Options.ConfigurationExtensions": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "VOapXeO3lhBH0zYoyAH7tjapuo4V5pTHlevPpiSHueEquAajqd5nF0mttm+h/uE/exwAEuM5s26SzOJtletE3w==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.8",
+ "Microsoft.Extensions.Configuration.Binder": "10.0.8",
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8",
+ "Microsoft.Extensions.Options": "10.0.8",
+ "Microsoft.Extensions.Primitives": "10.0.8"
+ }
+ },
+ "Microsoft.Extensions.Primitives": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "OBPo4nYhMyIbtueoC10CBm6AGAbo/A9IV8QQ/6ryZS7VvmqpGT7hunazeHLxFawRzn3oLOq4jhqhpBX4tfswWQ=="
+ },
+ "Microsoft.Extensions.Telemetry.Abstractions": {
+ "type": "Transitive",
+ "resolved": "9.10.0",
+ "contentHash": "hJflG5if8NqElmybxXDf38d4EPopOo9H+Qg6l5LKTsavqE4CFdA5DIPb9+jjAeL22FN+rs6KuuEIuBPS4PNXvw==",
+ "dependencies": {
+ "Microsoft.Extensions.Compliance.Abstractions": "9.10.0",
+ "Microsoft.Extensions.Logging.Abstractions": "9.0.10",
+ "Microsoft.Extensions.ObjectPool": "9.0.10",
+ "Microsoft.Extensions.Options": "9.0.10"
+ }
+ },
+ "Microsoft.Extensions.TimeProvider.Testing": {
+ "type": "Transitive",
+ "resolved": "10.6.0",
+ "contentHash": "qQDiaYWpvIymGbu+kXaMDS8YdqfeQkv6DOxPF2GSwC+eSzIKqOOnSP34TYt7gKqvB7p8/aSptexnW6nF0CUdnw=="
+ },
+ "Microsoft.Identity.Client": {
+ "type": "Transitive",
+ "resolved": "4.66.1",
+ "contentHash": "mE+m3pZ7zSKocSubKXxwZcUrCzLflC86IdLxrVjS8tialy0b1L+aECBqRBC/ykcPlB4y7skg49TaTiA+O2UfDw==",
+ "dependencies": {
+ "Microsoft.IdentityModel.Abstractions": "6.35.0"
+ }
+ },
+ "Microsoft.Identity.Client.Extensions.Msal": {
+ "type": "Transitive",
+ "resolved": "4.61.3",
+ "contentHash": "PWnJcznrSGr25MN8ajlc2XIDW4zCFu0U6FkpaNLEWLgd1NgFCp5uDY3mqLDgM8zCN8hqj8yo5wHYfLB2HjcdGw==",
+ "dependencies": {
+ "Microsoft.Identity.Client": "4.61.3",
+ "System.Security.Cryptography.ProtectedData": "4.5.0"
+ }
+ },
+ "Microsoft.Identity.Web.Certificateless": {
+ "type": "Transitive",
+ "resolved": "3.3.0",
+ "contentHash": "ybEVPCLeJFuTCDVTtt3OlD5n+CYQgUAzmn0YZw+Z4NR5XwB4iGQ/zMqQ+ruJfgoKGWe6BTl0vCfsv1O4XPqCvg==",
+ "dependencies": {
+ "Microsoft.Extensions.Logging.Abstractions": "2.1.0",
+ "Microsoft.Identity.Client": "4.66.1",
+ "Microsoft.IdentityModel.JsonWebTokens": "8.1.2"
+ }
+ },
+ "Microsoft.IdentityModel.Abstractions": {
+ "type": "Transitive",
+ "resolved": "8.16.0",
+ "contentHash": "gSxKLWRZzBpIsEoeUPkxfywNCCvRvl7hkq146XHPk5vOQc9izSf1I+uL1vh4y2U19QPxd9Z8K/8AdWyxYz2lSg=="
+ },
+ "Microsoft.IdentityModel.JsonWebTokens": {
+ "type": "Transitive",
+ "resolved": "8.16.0",
+ "contentHash": "prBU72cIP4V8E9fhN+o/YdskTsLeIcnKPbhZf0X6mD7fdxoZqnS/NdEkSr+9Zp+2q7OZBOMfNBKGbTbhXODO4w==",
+ "dependencies": {
+ "Microsoft.IdentityModel.Tokens": "8.16.0"
+ }
+ },
+ "Microsoft.IdentityModel.Logging": {
+ "type": "Transitive",
+ "resolved": "8.16.0",
+ "contentHash": "MTzXmETkNQPACR7/XCXM1OGM6oU9RkyibqeJRtO9Ndew2LnGjMf9Atqj2VSf4XC27X0FQycUAlzxxEgQMWn2xQ==",
+ "dependencies": {
+ "Microsoft.IdentityModel.Abstractions": "8.16.0"
+ }
+ },
+ "Microsoft.IdentityModel.Protocols": {
+ "type": "Transitive",
+ "resolved": "8.16.0",
+ "contentHash": "UFrU7d46UTsPQTa2HIEIpB9H1uJe1BW9FLw5uhEJ2ZuKdur8bcUA/bO5caq5dlBt5gNJeRIB3QQXYNs5fCQCZA==",
+ "dependencies": {
+ "Microsoft.IdentityModel.Tokens": "8.16.0"
+ }
+ },
+ "Microsoft.IdentityModel.Protocols.OpenIdConnect": {
+ "type": "Transitive",
+ "resolved": "8.16.0",
+ "contentHash": "h4yVXyJsEBBX5lg2G5ftMsi5JzcNEGAzrNphA6DQ6eOd8P0s+cDCOyPwVTYLePZvJL5unbPvYIvzrbTXzFjXnQ==",
+ "dependencies": {
+ "Microsoft.IdentityModel.Protocols": "8.16.0",
+ "System.IdentityModel.Tokens.Jwt": "8.16.0"
+ }
+ },
+ "Microsoft.IdentityModel.Tokens": {
+ "type": "Transitive",
+ "resolved": "8.16.0",
+ "contentHash": "rtViGJcGsN7WcfUNErwNeQgjuU5cJNl6FDQsfi9TncwO+Epzn0FTfBsg3YuFW1Q0Ch/KPxaVdjLw3/+5Z5ceFQ==",
+ "dependencies": {
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.0",
+ "Microsoft.IdentityModel.Logging": "8.16.0"
+ }
+ },
+ "Microsoft.NETCore.Platforms": {
+ "type": "Transitive",
+ "resolved": "1.1.0",
+ "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A=="
+ },
+ "Microsoft.OpenApi": {
+ "type": "Transitive",
+ "resolved": "2.4.1",
+ "contentHash": "u7QhXCISMQuab3flasb1hoaiERmUqyWsW7tmQODyILoQ7mJV5IRGM+2KKZYo0QUfC13evEOcHAb6TPWgqEQtrw=="
+ },
+ "Microsoft.Rest.ClientRuntime": {
+ "type": "Transitive",
+ "resolved": "2.3.24",
+ "contentHash": "hZH7XgM3eV2jFrnq7Yf0nBD4WVXQzDrer2gEY7HMNiwio2hwDsTHO6LWuueNQAfRpNp4W7mKxcXpwXUiuVIlYw==",
+ "dependencies": {
+ "Newtonsoft.Json": "10.0.3"
+ }
+ },
+ "Microsoft.SqlServer.Server": {
+ "type": "Transitive",
+ "resolved": "1.0.0",
+ "contentHash": "N4KeF3cpcm1PUHym1RmakkzfkEv3GRMyofVv40uXsQhCQeglr2OHNcUk2WOG51AKpGO8ynGpo9M/kFXSzghwug=="
+ },
+ "Microsoft.TestPlatform.ObjectModel": {
+ "type": "Transitive",
+ "resolved": "18.0.1",
+ "contentHash": "qT/mwMcLF9BieRkzOBPL2qCopl8hQu6A1P7JWAoj/FMu5i9vds/7cjbJ/LLtaiwWevWLAeD5v5wjQJ/l6jvhWQ=="
+ },
+ "Microsoft.TestPlatform.TestHost": {
+ "type": "Transitive",
+ "resolved": "18.0.1",
+ "contentHash": "uDJKAEjFTaa2wHdWlfo6ektyoh+WD4/Eesrwb4FpBFKsLGehhACVnwwTI4qD3FrIlIEPlxdXg3SyrYRIcO+RRQ==",
+ "dependencies": {
+ "Microsoft.TestPlatform.ObjectModel": "18.0.1",
+ "Newtonsoft.Json": "13.0.3"
+ }
+ },
+ "MimeKit": {
+ "type": "Transitive",
+ "resolved": "4.16.0",
+ "contentHash": "X0LFxeM4gPRIhODyY/HYS9b+zRZ7y//v59rFzgS6wLxcPuZThnMtNZHtrr0fjLyRRkg3gqJBtvW36XfUzZ7Djw==",
+ "dependencies": {
+ "BouncyCastle.Cryptography": "2.6.2",
+ "System.Security.Cryptography.Pkcs": "10.0.0"
+ }
+ },
+ "MySqlConnector": {
+ "type": "Transitive",
+ "resolved": "2.3.5",
+ "contentHash": "AmEfUPkFl+Ev6jJ8Dhns3CYHBfD12RHzGYWuLt6DfG6/af6YvOMyPz74ZPPjBYQGRJkumD2Z48Kqm8s5DJuhLA==",
+ "dependencies": {
+ "Microsoft.Extensions.Logging.Abstractions": "7.0.1"
+ }
+ },
+ "NETStandard.Library": {
+ "type": "Transitive",
+ "resolved": "1.6.1",
+ "contentHash": "WcSp3+vP+yHNgS8EV5J7pZ9IRpeDuARBPN28by8zqff1wJQXm26PVU8L3/fYLBJVU7BtDyqNVWq2KlCVvSSR4A==",
+ "dependencies": {
+ "Microsoft.NETCore.Platforms": "1.1.0"
+ }
+ },
+ "Newtonsoft.Json": {
+ "type": "Transitive",
+ "resolved": "13.0.3",
+ "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
+ },
+ "Npgsql": {
+ "type": "Transitive",
+ "resolved": "8.0.3",
+ "contentHash": "6WEmzsQJCZAlUG1pThKg/RmeF6V+I0DmBBBE/8YzpRtEzhyZzKcK7ulMANDm5CkxrALBEC8H+5plxHWtIL7xnA==",
+ "dependencies": {
+ "Microsoft.Extensions.Logging.Abstractions": "8.0.0"
+ }
+ },
+ "Npgsql.EntityFrameworkCore.PostgreSQL": {
+ "type": "Transitive",
+ "resolved": "8.0.4",
+ "contentHash": "/hHd9MqTRVDgIpsToCcxMDxZqla0HAQACiITkq1+L9J2hmHKV6lBAPlauF+dlNSfHpus7rrljWx4nAanKD6qAw==",
+ "dependencies": {
+ "Microsoft.EntityFrameworkCore": "8.0.4",
+ "Microsoft.EntityFrameworkCore.Abstractions": "8.0.4",
+ "Microsoft.EntityFrameworkCore.Relational": "8.0.4",
+ "Npgsql": "8.0.3"
+ }
+ },
+ "NSec.Cryptography": {
+ "type": "Transitive",
+ "resolved": "22.4.0",
+ "contentHash": "lEntcPYd7h3aZ8xxi/y/4TML7o8w0GEGqd+w4L1omqFLbdCBmhxJAeO2YBmv/fXbJKgKCQLm7+TD4bR605PEUQ==",
+ "dependencies": {
+ "libsodium": "[1.0.18.2, 1.0.19)"
+ }
+ },
+ "OneOf": {
+ "type": "Transitive",
+ "resolved": "3.0.271",
+ "contentHash": "pqpqeK8xQGggExhr4tesVgJkjdn+9HQAO0QgrYV2hFjE3y90okzk1kQMntMiUOGfV7FrCUfKPaVvPBD4IANqKg=="
+ },
+ "OpenTelemetry": {
+ "type": "Transitive",
+ "resolved": "1.15.3",
+ "contentHash": "N0i6WjPoHPbZyms1ugbDIFAJFuGlpeExJMU/+XSL0lQRUkg/D0utFkDoLXf8Z1km5B+xVZ2GyMXXiX8qdeNmPg==",
+ "dependencies": {
+ "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0",
+ "Microsoft.Extensions.Logging.Configuration": "10.0.0",
+ "OpenTelemetry.Api.ProviderBuilderExtensions": "1.15.3"
+ }
+ },
+ "OpenTelemetry.Api": {
+ "type": "Transitive",
+ "resolved": "1.15.3",
+ "contentHash": "fX+fkCysfPut+qCcT3bKqyX4QN9Saf4CgX8HLOHywEVD+Xr7sULtfuypITpoDysjx8R59dn/3mWhgimMH8cm/g=="
+ },
+ "OpenTelemetry.Api.ProviderBuilderExtensions": {
+ "type": "Transitive",
+ "resolved": "1.15.3",
+ "contentHash": "SYn0lqYDwLMWhv/zlNGsQcl2yX++yTumanX46bmOZE/ZDOd1WjPBO2kZaZgKLEZTZk48pavIFGJ6vOvxXgWVFQ==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0",
+ "OpenTelemetry.Api": "1.15.3"
+ }
+ },
+ "OpenTelemetry.Exporter.OpenTelemetryProtocol": {
+ "type": "Transitive",
+ "resolved": "1.15.3",
+ "contentHash": "FEXJepcseTGbATiCkUfP7ipoFEYYfl/0UmmUwi0KxCPg9PaUA8ab2P1LGopK+/HExasJ1ZutFhZrN6WvUIR23g==",
+ "dependencies": {
+ "OpenTelemetry": "1.15.3"
+ }
+ },
+ "OpenTelemetry.Extensions.Hosting": {
+ "type": "Transitive",
+ "resolved": "1.15.3",
+ "contentHash": "u8n/W8yIlqv0BXZmvId1iVaeWXG42tGKdTkuLYg5g57Y/r9CeUNzqtrSHNdG5IoO8iPX79w3v+WsbAHgUQbfeg==",
+ "dependencies": {
+ "Microsoft.Extensions.Hosting.Abstractions": "10.0.0",
+ "OpenTelemetry": "1.15.3"
+ }
+ },
+ "OpenTelemetry.Instrumentation.AspNetCore": {
+ "type": "Transitive",
+ "resolved": "1.15.0",
+ "contentHash": "mte1nRYefxjed2syXgVWq3UCfMKO7MkebvTZmf0O1aLgVgCktLsVjQ6mftyjIbWGBBCHN0wg+Glxj8BSFS70pQ==",
+ "dependencies": {
+ "OpenTelemetry.Api.ProviderBuilderExtensions": "[1.15.0, 2.0.0)"
+ }
+ },
+ "OpenTelemetry.Instrumentation.EntityFrameworkCore": {
+ "type": "Transitive",
+ "resolved": "1.12.0-beta.2",
+ "contentHash": "4D2PLiJWbBbQbauojkIflT11WGVXoRU+xgox1mvOkpfm7YXIfwTtROOlcdscS51sMh5fgwjGKJtLWpLKppe7dw==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "9.0.0",
+ "Microsoft.Extensions.Options": "9.0.0",
+ "OpenTelemetry.Api.ProviderBuilderExtensions": "[1.12.0, 2.0.0)"
+ }
+ },
+ "OpenTelemetry.Instrumentation.Http": {
+ "type": "Transitive",
+ "resolved": "1.15.0",
+ "contentHash": "uToc7bUp8IEdb0ny9mKsL6FrrYelINPzxxiSShJgOf4XmQc4Azww6S5RjRj24YhsOn2a1MABOrxfVTZXtDk4Eg==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "10.0.0",
+ "Microsoft.Extensions.Options": "10.0.0",
+ "OpenTelemetry.Api.ProviderBuilderExtensions": "[1.15.0, 2.0.0)"
+ }
+ },
+ "OpenTelemetry.Instrumentation.Runtime": {
+ "type": "Transitive",
+ "resolved": "1.15.0",
+ "contentHash": "OOvpqR/j2Pb6+tWhHNODIbSJ53Or/MDtTiXEyrsWI02K2lLAgvBFcxUOrHggS/8015cYR3AdSaXv6NZrkz5yQA==",
+ "dependencies": {
+ "OpenTelemetry.Api": "[1.15.0, 2.0.0)"
+ }
+ },
+ "OpenTelemetry.Instrumentation.SqlClient": {
+ "type": "Transitive",
+ "resolved": "1.12.0-beta.3",
+ "contentHash": "w1cOTM5U6c9MYbBALgqynwGNuGGn6uxbh0hV1LW7zmsQyq6e4kJrW0jIMcGgYIEYnIomOvUwoTEwEFb1ZClAeg==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "9.0.0",
+ "Microsoft.Extensions.Options": "9.0.0",
+ "OpenTelemetry.Api.ProviderBuilderExtensions": "[1.12.0, 2.0.0)"
+ }
+ },
+ "Otp.NET": {
+ "type": "Transitive",
+ "resolved": "1.4.0",
+ "contentHash": "Fk1NKc0lWmlo6LAFYpFJInRgFKt72knRNEvxndDYoQHFwYOPXav+WEUBvQA0k4lxq5xt0SymrZ+oi0F/G40bPQ=="
+ },
+ "Pipelines.Sockets.Unofficial": {
+ "type": "Transitive",
+ "resolved": "2.2.8",
+ "contentHash": "zG2FApP5zxSx6OcdJQLbZDk2AVlN2BNQD6MorwIfV6gVj0RRxWPEp2LXAxqDGZqeNV1Zp0BNPcNaey/GXmTdvQ=="
+ },
+ "Pomelo.EntityFrameworkCore.MySql": {
+ "type": "Transitive",
+ "resolved": "8.0.2",
+ "contentHash": "XjnlcxVBLnEMbyEc5cZzgZeDyLvAniACZQ04W1slWN0f4rmfNzl98gEMvHnFH0fMDF06z9MmgGi/Sr7hJ+BVnw==",
+ "dependencies": {
+ "Microsoft.EntityFrameworkCore.Relational": "[8.0.2, 8.0.999]",
+ "MySqlConnector": "2.3.5"
+ }
+ },
+ "Quartz": {
+ "type": "Transitive",
+ "resolved": "3.15.1",
+ "contentHash": "XIbhzUAKSm3xdl1ORLPnK7mc5XANP3cuvYQhCtuX/8888IN41e9OXJak4R9OlmAGRnyAMqHE40yojVa89NS1wg==",
+ "dependencies": {
+ "Microsoft.Extensions.Logging.Abstractions": "2.1.1"
+ }
+ },
+ "Quartz.Extensions.DependencyInjection": {
+ "type": "Transitive",
+ "resolved": "3.15.1",
+ "contentHash": "LinB9z54aPn49C/DGM1v3OflX2nosrEo4zNz10vfYqcCndFJ8MNU9k++Ap9T7vxeZc355WStPDggpX60TYj1Lg==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration.Abstractions": "9.0.0",
+ "Microsoft.Extensions.Options": "9.0.0",
+ "Quartz": "3.15.1"
+ }
+ },
+ "Quartz.Extensions.Hosting": {
+ "type": "Transitive",
+ "resolved": "3.15.1",
+ "contentHash": "svqLTEnVLb0VPUcNCd/khRqagwxM/yybUZ2sEOd7HFdPO+5dAOttL+ARtXSyBeaGWPWAaxY4VvU7pJTZzYhORw==",
+ "dependencies": {
+ "Microsoft.Extensions.Hosting.Abstractions": "9.0.0",
+ "Quartz.Extensions.DependencyInjection": "3.15.1"
+ }
+ },
+ "RabbitMQ.Client": {
+ "type": "Transitive",
+ "resolved": "7.1.2",
+ "contentHash": "y3c6ulgULScWthHw5PLM1ShHRLhxg0vCtzX/hh61gRgNecL3ZC3WoBW2HYHoXOVRqTl99Br9E7CZEytGZEsCyQ==",
+ "dependencies": {
+ "System.Threading.RateLimiting": "8.0.0"
+ }
+ },
+ "RichardSzalay.MockHttp": {
+ "type": "Transitive",
+ "resolved": "7.0.0",
+ "contentHash": "QwnauYiaywp65QKFnP+wvgiQ2D8Pv888qB2dyfd7MSVDF06sIvxqASenk+RxsWybyyt+Hu1Y251wQxpHTv3UYg=="
+ },
+ "SendGrid": {
+ "type": "Transitive",
+ "resolved": "9.29.3",
+ "contentHash": "nb/zHePecN9U4/Bmct+O+lpgK994JklbCCNMIgGPOone/DngjQoMCHeTvkl+m0Nglvm0dqMEshmvB4fO8eF3dA==",
+ "dependencies": {
+ "Newtonsoft.Json": "13.0.1",
+ "starkbank-ecdsa": "[1.3.3, 2.0.0)"
+ }
+ },
+ "Serilog": {
+ "type": "Transitive",
+ "resolved": "2.10.0",
+ "contentHash": "+QX0hmf37a0/OZLxM3wL7V6/ADvC1XihXN4Kq/p6d8lCPfgkRdiuhbWlMaFjR9Av0dy5F0+MBeDmDdRZN/YwQA=="
+ },
+ "Serilog.Extensions.Logging": {
+ "type": "Transitive",
+ "resolved": "3.1.0",
+ "contentHash": "IWfem7wfrFbB3iw1OikqPFNPEzfayvDuN4WP7Ue1AVFskalMByeWk3QbtUXQR34SBkv1EbZ3AySHda/ErDgpcg==",
+ "dependencies": {
+ "Microsoft.Extensions.Logging": "2.0.0",
+ "Serilog": "2.9.0"
+ }
+ },
+ "Serilog.Extensions.Logging.File": {
+ "type": "Transitive",
+ "resolved": "3.0.0",
+ "contentHash": "bUYjMHn7NhpK+/8HDftG7+G5hpWzD49XTSvLoUFZGgappDa6FoseqFOsLrjLRjwe1zM+igH5mySFJv3ntb+qcg==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration.Abstractions": "6.0.0",
+ "Microsoft.Extensions.Configuration.Binder": "6.0.0",
+ "Serilog": "2.10.0",
+ "Serilog.Extensions.Logging": "3.1.0",
+ "Serilog.Formatting.Compact": "1.1.0",
+ "Serilog.Sinks.Async": "1.5.0",
+ "Serilog.Sinks.RollingFile": "3.3.0"
+ }
+ },
+ "Serilog.Formatting.Compact": {
+ "type": "Transitive",
+ "resolved": "1.1.0",
+ "contentHash": "pNroKVjo+rDqlxNG5PXkRLpfSCuDOBY0ri6jp9PLe505ljqwhwZz8ospy2vWhQlFu5GkIesh3FcDs4n7sWZODA==",
+ "dependencies": {
+ "Serilog": "2.8.0"
+ }
+ },
+ "Serilog.Sinks.Async": {
+ "type": "Transitive",
+ "resolved": "1.5.0",
+ "contentHash": "csHYIqAwI4Gy9oAhXYRwxGrQEAtBg3Ep7WaCzsnA1cZuBZjVAU0n7hWaJhItjO7hbLHh/9gRVxALCUB4Dv+gZw==",
+ "dependencies": {
+ "Serilog": "2.9.0"
+ }
+ },
+ "Serilog.Sinks.File": {
+ "type": "Transitive",
+ "resolved": "3.2.0",
+ "contentHash": "VHbo68pMg5hwSWrzLEdZv5b/rYmIgHIRhd4d5rl8GnC5/a8Fr+RShT5kWyeJOXax1el6mNJ+dmHDOVgnNUQxaw==",
+ "dependencies": {
+ "Serilog": "2.3.0"
+ }
+ },
+ "Serilog.Sinks.RollingFile": {
+ "type": "Transitive",
+ "resolved": "3.3.0",
+ "contentHash": "2lT5X1r3GH4P0bRWJfhA7etGl8Q2Ipw9AACvtAHWRUSpYZ42NGVyHoVs2ALBZ/cAkkS+tA4jl80Zie144eLQPg==",
+ "dependencies": {
+ "Serilog.Sinks.File": "3.2.0"
+ }
+ },
+ "SQLitePCLRaw.bundle_e_sqlite3": {
+ "type": "Transitive",
+ "resolved": "2.1.6",
+ "contentHash": "BmAf6XWt4TqtowmiWe4/5rRot6GerAeklmOPfviOvwLoF5WwgxcJHAxZtySuyW9r9w+HLILnm8VfJFLCUJYW8A==",
+ "dependencies": {
+ "SQLitePCLRaw.lib.e_sqlite3": "2.1.6",
+ "SQLitePCLRaw.provider.e_sqlite3": "2.1.6"
+ }
+ },
+ "SQLitePCLRaw.core": {
+ "type": "Transitive",
+ "resolved": "2.1.6",
+ "contentHash": "wO6v9GeMx9CUngAet8hbO7xdm+M42p1XeJq47ogyRoYSvNSp0NGLI+MgC0bhrMk9C17MTVFlLiN6ylyExLCc5w=="
+ },
+ "SQLitePCLRaw.lib.e_sqlite3": {
+ "type": "Transitive",
+ "resolved": "2.1.6",
+ "contentHash": "2ObJJLkIUIxRpOUlZNGuD4rICpBnrBR5anjyfUFQep4hMOIeqW+XGQYzrNmHSVz5xSWZ3klSbh7sFR6UyDj68Q=="
+ },
+ "SQLitePCLRaw.provider.e_sqlite3": {
+ "type": "Transitive",
+ "resolved": "2.1.6",
+ "contentHash": "PQ2Oq3yepLY4P7ll145P3xtx2bX8xF4PzaKPRpw9jZlKvfe4LE/saAV82inND9usn1XRpmxXk7Lal3MTI+6CNg==",
+ "dependencies": {
+ "SQLitePCLRaw.core": "2.1.6"
+ }
+ },
+ "StackExchange.Redis": {
+ "type": "Transitive",
+ "resolved": "2.8.31",
+ "contentHash": "RCHVQa9Zke8k0oBgJn1Yl6BuYy8i6kv+sdMObiH60nOwD6QvWAjxdDwOm+LO78E8WsGiPqgOuItkz98fPS6haQ==",
+ "dependencies": {
+ "Microsoft.Extensions.Logging.Abstractions": "6.0.0",
+ "Pipelines.Sockets.Unofficial": "2.2.8"
+ }
+ },
+ "starkbank-ecdsa": {
+ "type": "Transitive",
+ "resolved": "1.3.3",
+ "contentHash": "OblOaKb1enXn+dSp7tsx9yjwV+/BEKM9jFhshIkZTwCk7LuTFTp+wSon6rFzuPiIiTGtvVWQNUw2slHjGktJog=="
+ },
+ "Stripe.net": {
+ "type": "Transitive",
+ "resolved": "48.5.0",
+ "contentHash": "wOAZYR0EnrLMok/ScfVOpTxjci+n3vFP0A7w/BE63yJdkRSDwZVCJIhlOjeJvgyQnMX8ZbwDAHMaxaiDa0Z5TA==",
+ "dependencies": {
+ "Newtonsoft.Json": "13.0.3",
+ "System.Configuration.ConfigurationManager": "8.0.0"
+ }
+ },
+ "Swashbuckle.AspNetCore": {
+ "type": "Transitive",
+ "resolved": "10.1.7",
+ "contentHash": "vgef8DPT411JU5JjHiDbr0WOxsIVuAvegPGtqmm4Na4JRl/264dfBJcGkiPHsAr5P+Vda+qN1rZKRtBl1rF9aA==",
+ "dependencies": {
+ "Microsoft.Extensions.ApiDescription.Server": "10.0.0",
+ "Swashbuckle.AspNetCore.Swagger": "10.1.7",
+ "Swashbuckle.AspNetCore.SwaggerGen": "10.1.7",
+ "Swashbuckle.AspNetCore.SwaggerUI": "10.1.7"
+ }
+ },
+ "Swashbuckle.AspNetCore.Swagger": {
+ "type": "Transitive",
+ "resolved": "10.1.7",
+ "contentHash": "EjLibt/d/QuRv170GoihTbcPUpgzSFm2WKHhnGJFZQ03JYzfuitsM79azaAR8NBwRunU7yScSX6HRE5JUlrEMQ==",
+ "dependencies": {
+ "Microsoft.OpenApi": "2.4.1"
+ }
+ },
+ "Swashbuckle.AspNetCore.SwaggerGen": {
+ "type": "Transitive",
+ "resolved": "10.1.7",
+ "contentHash": "PuubO9BjvNn6U3D9kLpuWKY1JtziWw7SsGBq0age1E50uQjQ8Fzl8s0EwzrLfANqYJNgDnJi9l7N1QxcGVB2Zw==",
+ "dependencies": {
+ "Swashbuckle.AspNetCore.Swagger": "10.1.7"
+ }
+ },
+ "Swashbuckle.AspNetCore.SwaggerUI": {
+ "type": "Transitive",
+ "resolved": "10.1.7",
+ "contentHash": "iJo3ODyUb/M8Vm8AH1r9y9iAba0w95xsCn3zFVl96ISRHbTDWxi+l7oFVCZqUEdjd97B8VMDPnMliWAdomR8uw=="
+ },
+ "System.ClientModel": {
+ "type": "Transitive",
+ "resolved": "1.6.1",
+ "contentHash": "xcHHhDqB5MnOOY8yIn64Vzp6gtBEs6k5J1hluG04CrShSvQNXOx4PSDs7wJiXLDidlY/FZJmxJdKTKskyJwjvw==",
+ "dependencies": {
+ "Microsoft.Extensions.Logging.Abstractions": "8.0.3",
+ "System.Memory.Data": "8.0.1"
+ }
+ },
+ "System.Configuration.ConfigurationManager": {
+ "type": "Transitive",
+ "resolved": "9.0.13",
+ "contentHash": "GbBrJq9S/gYpHzm7Pxx6Y5tDyfSfyxW6tlP5oiKJV38uf19Wp+GIIAnWfyL1zmNiz1+EjwVapw2WkBFvvqKQzg==",
+ "dependencies": {
+ "System.Diagnostics.EventLog": "9.0.13",
+ "System.Security.Cryptography.ProtectedData": "9.0.13"
+ }
+ },
+ "System.Diagnostics.EventLog": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "+Ro7WgIom+BDNH+YhTuZKL6QJ0ctfOpTyfUG/h3aU5KwXt3OaNf0wYWrTvoBUj+34Dy5V8dN9yCco1hAJQ4txw=="
+ },
+ "System.Formats.Cbor": {
+ "type": "Transitive",
+ "resolved": "6.0.0",
+ "contentHash": "mGaLOoiw7KurJagOOcIsWUoCT5ACIiGxKlCcbYQASefBGXjnCcKTq5Hdjb94eEAKg38zXKlHw4c6EjzgBl9dIw=="
+ },
+ "System.IdentityModel.Tokens.Jwt": {
+ "type": "Transitive",
+ "resolved": "8.16.0",
+ "contentHash": "rrs2u7DRMXQG2yh0oVyF/vLwosfRv20Ld2iEpYcKwQWXHjfV+gFXNQsQ9p008kR9Ou4pxBs68Q6/9zC8Gi1wjg==",
+ "dependencies": {
+ "Microsoft.IdentityModel.JsonWebTokens": "8.16.0",
+ "Microsoft.IdentityModel.Tokens": "8.16.0"
+ }
+ },
+ "System.IO.Hashing": {
+ "type": "Transitive",
+ "resolved": "8.0.0",
+ "contentHash": "ne1843evDugl0md7Fjzy6QjJrzsjh46ZKbhf8GwBXb5f/gw97J4bxMs0NQKifDuThh/f0bZ0e62NPl1jzTuRqA=="
+ },
+ "System.Memory.Data": {
+ "type": "Transitive",
+ "resolved": "8.0.1",
+ "contentHash": "BVYuec3jV23EMRDeR7Dr1/qhx7369dZzJ9IWy2xylvb4YfXsrUxspWc4UWYid/tj4zZK58uGZqn2WQiaDMhmAg=="
+ },
+ "System.Security.Cryptography.Pkcs": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "2wOycCqMyg9Tu+SDP03FFwDEWBni/3xOKgt0bRXplOvyeIcUJmWO7m3gTCF2mIdtQLROLtOP5VwWRT8YBwP/bA=="
+ },
+ "System.Security.Cryptography.ProtectedData": {
+ "type": "Transitive",
+ "resolved": "9.0.13",
+ "contentHash": "t8S9IDpjJKsLpLkeBdW8cWtcPyYqrGu93Dej1RO6WwuL/lkFSqWlan3rMJfortqz1mRIh+sys2AFsSA6jWJ3Jg=="
+ },
+ "System.Security.Cryptography.Xml": {
+ "type": "Transitive",
+ "resolved": "10.0.8",
+ "contentHash": "Fb+L55vEJaf8RCOzrzN564sfyCL8SZEfce9z6XkuhHg+294SBfyS4fIoLU3EljofDCkp+EKDeleI8ug5WO2NtA==",
+ "dependencies": {
+ "System.Security.Cryptography.Pkcs": "10.0.8"
+ }
+ },
+ "System.Threading.RateLimiting": {
+ "type": "Transitive",
+ "resolved": "8.0.0",
+ "contentHash": "7mu9v0QDv66ar3DpGSZHg9NuNcxDaaAcnMULuZlaTpP9+hwXhrxNGsF5GmLkSHxFdb5bBc1TzeujsRgTrPWi+Q=="
+ },
+ "System.Xml.XPath.XmlDocument": {
+ "type": "Transitive",
+ "resolved": "4.3.0",
+ "contentHash": "A/uxsWi/Ifzkmd4ArTLISMbfFs6XpRPsXZonrIqyTY70xi8t+mDtvSM5Os0RqyRDobjMBwIDHDL4NOIbkDwf7A=="
+ },
+ "xunit.abstractions": {
+ "type": "Transitive",
+ "resolved": "2.0.3",
+ "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg=="
+ },
+ "xunit.analyzers": {
+ "type": "Transitive",
+ "resolved": "1.10.0",
+ "contentHash": "Lw8CiDy5NaAWcO6keqD7iZHYUTIuCOcoFrUHw5Sv84ITZ9gFeDybdkVdH0Y2maSlP9fUjtENyiykT44zwFQIHA=="
+ },
+ "xunit.assert": {
+ "type": "Transitive",
+ "resolved": "2.6.6",
+ "contentHash": "74Cm9lAZOk5TKCz2MvCBCByKsS23yryOKDIMxH3XRDHXmfGM02jKZWzRA7g4mGB41GnBnv/pcWP3vUYkrCtEcg=="
+ },
+ "xunit.core": {
+ "type": "Transitive",
+ "resolved": "2.6.6",
+ "contentHash": "tqi7RfaNBqM7t8zx6QHryuBPzmotsZXKGaWnopQG2Ez5UV7JoWuyoNdT6gLpDIcKdGYey6YTXJdSr9IXDMKwjg==",
+ "dependencies": {
+ "xunit.extensibility.core": "[2.6.6]",
+ "xunit.extensibility.execution": "[2.6.6]"
+ }
+ },
+ "xunit.extensibility.core": {
+ "type": "Transitive",
+ "resolved": "2.6.6",
+ "contentHash": "ty6VKByzbx4Toj4/VGJLEnlmOawqZiMv0in/tLju+ftA+lbWuAWDERM+E52Jfhj4ZYHrAYVa14KHK5T+dq0XxA==",
+ "dependencies": {
+ "xunit.abstractions": "2.0.3"
+ }
+ },
+ "xunit.extensibility.execution": {
+ "type": "Transitive",
+ "resolved": "2.6.6",
+ "contentHash": "UDjIVGj2TepVKN3n32/qXIdb3U6STwTb9L6YEwoQO2A8OxiJS5QAVv2l1aT6tDwwv/9WBmm8Khh/LyHALipcng==",
+ "dependencies": {
+ "xunit.extensibility.core": "[2.6.6]"
+ }
+ },
+ "YubicoDotNetClient": {
+ "type": "Transitive",
+ "resolved": "1.2.0",
+ "contentHash": "uP5F3Ko1gqZi3lwS2R/jAAwhBxXs/6PKDpS6FdQjsBA5qmF0hQmbtfxM6QHTXOMoWbUtfetG7+LtgmG8T5zDIg==",
+ "dependencies": {
+ "NETStandard.Library": "1.6.1"
+ }
+ },
+ "ZiggyCreatures.FusionCache": {
+ "type": "Transitive",
+ "resolved": "2.0.2",
+ "contentHash": "nO6ysiVP/1S1zVZMzsK0xASeSUay27iIlK8GjeyTpIAmq5P4/0KOzV9AqlabZYFgzeQDAu5IcB39ela2w/HCwQ==",
+ "dependencies": {
+ "Microsoft.Extensions.Caching.Memory": "8.0.1"
+ }
+ },
+ "ZiggyCreatures.FusionCache.Backplane.StackExchangeRedis": {
+ "type": "Transitive",
+ "resolved": "2.0.2",
+ "contentHash": "E9KfGnhY+xcy8bmxoB/jJbfYwBlQwDD6c0v0P/Qr3IxGyJXyncza/45vFo5nI+5CggqczQ1GwQeEJqk0Lg8Q5g==",
+ "dependencies": {
+ "StackExchange.Redis": "2.8.31",
+ "ZiggyCreatures.FusionCache": "2.0.2"
+ }
+ },
+ "ZiggyCreatures.FusionCache.Serialization.SystemTextJson": {
+ "type": "Transitive",
+ "resolved": "2.0.2",
+ "contentHash": "gt5ia5PHpCxhnI0hr51Y6L/acrrU17OuBqiU3vJPFXZxS3R1pIj2hr6WGB5fzW64VZDVdHTYsD5gTr2Pu8I+QQ==",
+ "dependencies": {
+ "ZiggyCreatures.FusionCache": "2.0.2"
+ }
+ },
+ "admin": {
+ "type": "Project",
+ "dependencies": {
+ "Commercial.Core": "[2026.6.0, )",
+ "Commercial.Infrastructure.EntityFramework": "[2026.6.0, )",
+ "Core": "[2026.6.0, )",
+ "Migrator": "[2026.6.0, )",
+ "MySqlMigrations": "[2026.6.0, )",
+ "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )",
+ "OpenTelemetry.Extensions.Hosting": "[1.15.3, )",
+ "OpenTelemetry.Instrumentation.AspNetCore": "[1.15.0, )",
+ "OpenTelemetry.Instrumentation.EntityFrameworkCore": "[1.12.0-beta.2, )",
+ "OpenTelemetry.Instrumentation.Http": "[1.15.0, )",
+ "OpenTelemetry.Instrumentation.Runtime": "[1.15.0, )",
+ "OpenTelemetry.Instrumentation.SqlClient": "[1.12.0-beta.3, )",
+ "PostgresMigrations": "[2026.6.0, )",
+ "SharedWeb": "[2026.6.0, )",
+ "SqliteMigrations": "[2026.6.0, )"
+ }
+ },
+ "api": {
+ "type": "Project",
+ "dependencies": {
+ "AspNetCore.HealthChecks.SqlServer": "[8.0.2, 8.0.2]",
+ "AspNetCore.HealthChecks.Uris": "[8.0.1, 8.0.1]",
+ "Azure.Messaging.EventGrid": "[5.0.0, 5.0.0]",
+ "Commercial.Core": "[2026.6.0, )",
+ "Commercial.Infrastructure.EntityFramework": "[2026.6.0, )",
+ "Core": "[2026.6.0, )",
+ "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )",
+ "OpenTelemetry.Extensions.Hosting": "[1.15.3, )",
+ "OpenTelemetry.Instrumentation.AspNetCore": "[1.15.0, )",
+ "OpenTelemetry.Instrumentation.EntityFrameworkCore": "[1.12.0-beta.2, )",
+ "OpenTelemetry.Instrumentation.Http": "[1.15.0, )",
+ "OpenTelemetry.Instrumentation.Runtime": "[1.15.0, )",
+ "OpenTelemetry.Instrumentation.SqlClient": "[1.12.0-beta.3, )",
+ "SharedWeb": "[2026.6.0, )",
+ "Swashbuckle.AspNetCore": "[10.1.7, 10.1.7]"
+ }
+ },
+ "api.integrationtest": {
+ "type": "Project",
+ "dependencies": {
+ "Api": "[2026.6.0, )",
+ "IntegrationTestCommon": "[2026.6.0, )",
+ "Microsoft.Extensions.Diagnostics.Testing": "[9.10.0, 9.10.0]",
+ "Microsoft.NET.Test.Sdk": "[18.0.1, )",
+ "Seeder": "[2026.6.0, )",
+ "xunit": "[2.6.6, )"
+ }
+ },
+ "billing": {
+ "type": "Project",
+ "dependencies": {
+ "Commercial.Core": "[2026.6.0, )",
+ "Core": "[2026.6.0, )",
+ "MarkDig": "[1.1.0, 1.1.0]",
+ "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )",
+ "OpenTelemetry.Extensions.Hosting": "[1.15.3, )",
+ "OpenTelemetry.Instrumentation.AspNetCore": "[1.15.0, )",
+ "OpenTelemetry.Instrumentation.EntityFrameworkCore": "[1.12.0-beta.2, )",
+ "OpenTelemetry.Instrumentation.Http": "[1.15.0, )",
+ "OpenTelemetry.Instrumentation.Runtime": "[1.15.0, )",
+ "OpenTelemetry.Instrumentation.SqlClient": "[1.12.0-beta.3, )",
+ "SharedWeb": "[2026.6.0, )",
+ "Swashbuckle.AspNetCore": "[10.1.7, 10.1.7]"
+ }
+ },
+ "commercial.core": {
+ "type": "Project",
+ "dependencies": {
+ "Core": "[2026.6.0, )",
+ "CsvHelper": "[33.1.0, 33.1.0]"
+ }
+ },
+ "commercial.infrastructure.entityframework": {
+ "type": "Project",
+ "dependencies": {
+ "AutoMapper": "[14.0.0, 14.0.0]",
+ "Core": "[2026.6.0, )",
+ "Infrastructure.EntityFramework": "[2026.6.0, )"
+ }
+ },
+ "common": {
+ "type": "Project",
+ "dependencies": {
+ "AutoFixture.AutoNSubstitute": "[4.18.1, )",
+ "AutoFixture.Xunit2": "[4.18.1, )",
+ "Core": "[2026.6.0, )",
+ "Kralizek.AutoFixture.Extensions.MockHttp": "[2.2.1, 2.2.1]",
+ "Microsoft.Extensions.TimeProvider.Testing": "[10.6.0, 10.6.0]",
+ "Microsoft.NET.Test.Sdk": "[18.0.1, )",
+ "NSubstitute": "[5.1.0, )",
+ "xunit": "[2.6.6, )"
+ }
+ },
+ "core": {
+ "type": "Project",
+ "dependencies": {
+ "AWSSDK.SQS": "[4.0.2.5, 4.0.2.5]",
+ "AWSSDK.SimpleEmail": "[4.0.2.5, 4.0.2.5]",
+ "AspNetCoreRateLimit": "[5.0.0, 5.0.0]",
+ "AspNetCoreRateLimit.Redis": "[2.0.0, 2.0.0]",
+ "Azure.Data.Tables": "[12.11.0, 12.11.0]",
+ "Azure.Extensions.AspNetCore.DataProtection.Blobs": "[1.3.4, 1.3.4]",
+ "Azure.Messaging.ServiceBus": "[7.20.1, 7.20.1]",
+ "Azure.Storage.Blobs": "[12.26.0, 12.26.0]",
+ "Azure.Storage.Blobs.Batch": "[12.23.0, 12.23.0]",
+ "Azure.Storage.Queues": "[12.24.0, 12.24.0]",
+ "BitPay.Light": "[1.0.1907, 1.0.1907]",
+ "Braintree": "[5.36.0, 5.36.0]",
+ "DnsClient": "[1.8.0, 1.8.0]",
+ "Duende.IdentityServer": "[7.4.6, 7.4.6]",
+ "DuoUniversal": "[1.3.1, 1.3.1]",
+ "Fido2.AspNet": "[3.0.1, 3.0.1]",
+ "Handlebars.Net": "[2.1.6, 2.1.6]",
+ "LaunchDarkly.ServerSdk": "[8.11.0, 8.11.0]",
+ "MailKit": "[4.16.0, 4.16.0]",
+ "Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.8, 10.0.8]",
+ "Microsoft.AspNetCore.DataProtection": "[10.0.8, 10.0.8]",
+ "Microsoft.Azure.Cosmos": "[3.52.0, 3.52.0]",
+ "Microsoft.Azure.NotificationHubs": "[4.2.0, 4.2.0]",
+ "Microsoft.Bot.Builder": "[4.23.0, 4.23.0]",
+ "Microsoft.Bot.Builder.Integration.AspNet.Core": "[4.23.0, 4.23.0]",
+ "Microsoft.Bot.Connector": "[4.23.0, 4.23.0]",
+ "Microsoft.Data.SqlClient": "[7.0.0, 7.0.0]",
+ "Microsoft.Extensions.Caching.Cosmos": "[1.8.0, 1.8.0]",
+ "Microsoft.Extensions.Caching.SqlServer": "[10.0.8, 10.0.8]",
+ "Microsoft.Extensions.Caching.StackExchangeRedis": "[10.0.8, 10.0.8]",
+ "Microsoft.Extensions.Configuration.EnvironmentVariables": "[10.0.8, 10.0.8]",
+ "Microsoft.Extensions.Configuration.UserSecrets": "[10.0.8, 10.0.8]",
+ "Microsoft.Extensions.Identity.Stores": "[10.0.8, 10.0.8]",
+ "Newtonsoft.Json": "[13.0.3, 13.0.3]",
+ "OneOf": "[3.0.271, 3.0.271]",
+ "Otp.NET": "[1.4.0, 1.4.0]",
+ "Quartz": "[3.15.1, 3.15.1]",
+ "Quartz.Extensions.DependencyInjection": "[3.15.1, 3.15.1]",
+ "Quartz.Extensions.Hosting": "[3.15.1, 3.15.1]",
+ "RabbitMQ.Client": "[7.1.2, 7.1.2]",
+ "SendGrid": "[9.29.3, 9.29.3]",
+ "Serilog.Extensions.Logging.File": "[3.0.0, 3.0.0]",
+ "Stripe.net": "[48.5.0, 48.5.0]",
+ "YubicoDotNetClient": "[1.2.0, 1.2.0]",
+ "ZiggyCreatures.FusionCache": "[2.0.2, 2.0.2]",
+ "ZiggyCreatures.FusionCache.Backplane.StackExchangeRedis": "[2.0.2, 2.0.2]",
+ "ZiggyCreatures.FusionCache.Serialization.SystemTextJson": "[2.0.2, 2.0.2]"
+ }
+ },
+ "identity": {
+ "type": "Project",
+ "dependencies": {
+ "Core": "[2026.6.0, )",
+ "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )",
+ "OpenTelemetry.Extensions.Hosting": "[1.15.3, )",
+ "OpenTelemetry.Instrumentation.AspNetCore": "[1.15.0, )",
+ "OpenTelemetry.Instrumentation.EntityFrameworkCore": "[1.12.0-beta.2, )",
+ "OpenTelemetry.Instrumentation.Http": "[1.15.0, )",
+ "OpenTelemetry.Instrumentation.Runtime": "[1.15.0, )",
+ "OpenTelemetry.Instrumentation.SqlClient": "[1.12.0-beta.3, )",
+ "SharedWeb": "[2026.6.0, )"
+ }
+ },
+ "infrastructure.dapper": {
+ "type": "Project",
+ "dependencies": {
+ "Core": "[2026.6.0, )",
+ "Dapper": "[2.1.66, 2.1.66]"
+ }
+ },
+ "infrastructure.entityframework": {
+ "type": "Project",
+ "dependencies": {
+ "AutoMapper": "[14.0.0, 14.0.0]",
+ "Core": "[2026.6.0, )",
+ "Microsoft.EntityFrameworkCore.Relational": "[8.0.8, 8.0.8]",
+ "Microsoft.EntityFrameworkCore.SqlServer": "[8.0.8, 8.0.8]",
+ "Microsoft.EntityFrameworkCore.Sqlite": "[8.0.8, 8.0.8]",
+ "Npgsql.EntityFrameworkCore.PostgreSQL": "[8.0.4, 8.0.4]",
+ "Pomelo.EntityFrameworkCore.MySql": "[8.0.2, 8.0.2]",
+ "linq2db": "[5.4.1, 5.4.1]",
+ "linq2db.EntityFrameworkCore": "[8.1.0, 8.1.0]"
+ }
+ },
+ "integrationtestcommon": {
+ "type": "Project",
+ "dependencies": {
+ "Common": "[2026.6.0, )",
+ "Identity": "[2026.6.0, )",
+ "Microsoft.AspNetCore.Mvc.Testing": "[10.0.8, 10.0.8]",
+ "Migrator": "[2026.6.0, )",
+ "Seeder": "[2026.6.0, )"
+ }
+ },
+ "migrator": {
+ "type": "Project",
+ "dependencies": {
+ "Core": "[2026.6.0, )",
+ "Microsoft.Extensions.Logging": "[10.0.8, 10.0.8]",
+ "dbup-sqlserver": "[7.2.0, 7.2.0]"
+ }
+ },
+ "mysqlmigrations": {
+ "type": "Project",
+ "dependencies": {
+ "Core": "[2026.6.0, )",
+ "Infrastructure.EntityFramework": "[2026.6.0, )"
+ }
+ },
+ "postgresmigrations": {
+ "type": "Project",
+ "dependencies": {
+ "Core": "[2026.6.0, )",
+ "Infrastructure.EntityFramework": "[2026.6.0, )"
+ }
+ },
+ "rustsdk": {
+ "type": "Project"
+ },
+ "seeder": {
+ "type": "Project",
+ "dependencies": {
+ "Bogus": "[35.6.5, 35.6.5]",
+ "Core": "[2026.6.0, )",
+ "Infrastructure.EntityFramework": "[2026.6.0, )",
+ "RustSdk": "[2026.6.0, )",
+ "SharedWeb": "[2026.6.0, )"
+ }
+ },
+ "sharedweb": {
+ "type": "Project",
+ "dependencies": {
+ "Core": "[2026.6.0, )",
+ "Infrastructure.Dapper": "[2026.6.0, )",
+ "Infrastructure.EntityFramework": "[2026.6.0, )",
+ "Microsoft.Bot.Builder.Integration.AspNet.Core": "[4.23.0, 4.23.0]",
+ "Swashbuckle.AspNetCore.SwaggerGen": "[10.1.7, 10.1.7]"
+ }
+ },
+ "sqlitemigrations": {
+ "type": "Project",
+ "dependencies": {
+ "Core": "[2026.6.0, )",
+ "Infrastructure.EntityFramework": "[2026.6.0, )"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs
index 896e42006afc..e127e18f5ee4 100644
--- a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs
+++ b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs
@@ -5090,7 +5090,7 @@ public async Task HandleAsync_WhenBusinessTier_AndNoActiveSchedule_FallsBackToCo
subscriptionDiscounts:
[
new Discount { Coupon = new Coupon { Id = "sub-5", PercentOff = 5 } }
- ]);
+ ]);
var (organization, enterprise2020Plan, enterprisePlan, assignment, cohort, cohortId) =
BuildBusinessMigrationContext(coupon: "cohort-20");
@@ -5479,4 +5479,3 @@ private void StubActiveScheduleWithPhases(Subscription subscription, DateTime no
};
}
-
diff --git a/test/Common/Helpers/AssertExtensions.cs b/test/Common/Helpers/AssertExtensions.cs
new file mode 100644
index 000000000000..ede4656b4cca
--- /dev/null
+++ b/test/Common/Helpers/AssertExtensions.cs
@@ -0,0 +1,44 @@
+#nullable enable
+
+using System.Text.Json;
+using Xunit;
+
+namespace Bit.Test.Common.Helpers;
+
+public static class AssertExtensions
+{
+ extension(Assert)
+ {
+ public static async Task SuccessResponseAsync(HttpResponseMessage response)
+ {
+ if (response.IsSuccessStatusCode)
+ {
+ return;
+ }
+
+ var body = await response.Content.ReadAsStringAsync();
+ var formatted = TryFormatJson(body) ?? body;
+
+ Assert.Fail(
+ $"Expected success, got {(int)response.StatusCode} {response.ReasonPhrase}.\n\n" +
+ $"Response body:\n{formatted}");
+ }
+ }
+
+ private static string? TryFormatJson(string body)
+ {
+ if (string.IsNullOrWhiteSpace(body))
+ {
+ return null;
+ }
+ try
+ {
+ using var doc = JsonDocument.Parse(body);
+ return JsonSerializer.Serialize(doc, new JsonSerializerOptions { WriteIndented = true });
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+}
diff --git a/test/INTEGRATION_TEST.md b/test/INTEGRATION_TEST.md
new file mode 100644
index 000000000000..6170660c686c
--- /dev/null
+++ b/test/INTEGRATION_TEST.md
@@ -0,0 +1,239 @@
+# Integration Testing
+
+How to write integration tests for the Bitwarden server. Assumes familiarity with xUnit and ASP.NET Core's [`WebApplicationFactory`](https://learn.microsoft.com/en-us/dotnet/core/extensions/integration-testing). Database integration tests (repository-layer, cross-provider) are out of scope — see [Infrastructure.IntegrationTest](Infrastructure.IntegrationTest/).
+
+## TL;DR
+
+- **Drive tests through `HttpClient` where you can.** It's the ideal because a purely-HTTP suite can one day run against a real Bitwarden instance for deeper end-to-end validation. When an operation can't be expressed over HTTP — seeding domain state the API doesn't expose, forcing external state for the host to read back, invoking a command directly — an intent method that reaches into DI inside the fixture is fine. Flag those as gaps a future real-instance variant will need to skip.
+- **Wrap each project under test in a `FooApplicationFactory` class** that holds a `WebApplicationFactory` privately and primarily exposes `HttpClient` accessors plus intent-revealing methods (`RegisterUserAsync`, `LoginAsync`, `ConfirmRegistrationAsync`, …). Tests should call those rather than touching `Services` directly; the intent methods themselves may use DI when there's no HTTP equivalent.
+- Integration tests don't need a project of their own — they can live in the matching unit-test project (e.g., a test for `src/Api` goes in `test/Api.Test/`) or in a dedicated `*.IntegrationTest` project.
+- The default test database is an **in-memory SQLite connection the application factory owns**.
+- Use `IClassFixture` (or `IClassFixture` for multi-host) for state isolation. One instance per test class.
+
+The unifying principle: a `FooApplicationFactory` is a small, self-contained abstraction over one host. The same shape should one day be implementable against a real Bitwarden instance, with the in-process variant living in this repo and the real-instance variant doing the same work over HTTP. No shared base, no shared database abstraction.
+
+---
+
+## When to write an integration test
+
+Reach for one when:
+
+- The behavior depends on real DB state or relational invariants the unit layer can't express (cascades, constraints, repository SQL).
+- The flow spans multiple controllers, middleware, or the auth pipeline (`/connect/token` → resource endpoint).
+- The flow spans multiple hosts (e.g., Admin sends a link the Api consumes).
+
+Anything else — pure logic, validators, single-method behavior — belongs in a unit test with mocked dependencies. Integration tests are slower and noisier; keep them for the seams.
+
+---
+
+## Where the test goes
+
+Integration tests don't need a project of their own — splitting integration from unit by project is a choice, not a requirement. They can live in the matching `*.Test` project (`test/Api.Test/` for `src/Api`, `test/Admin.Test/` for `src/Admin`, etc.) or in a dedicated `*.IntegrationTest` project; pick whichever matches the surface you're working in and what's already there.
+
+When integration tests share a project with unit tests, an `Integration/` subfolder is a nice way to keep them visually separate. The unit-test project may need additional package references it didn't already have (`Microsoft.AspNetCore.Mvc.Testing`, `Microsoft.AspNetCore.TestHost`, `Microsoft.Data.Sqlite`, `Microsoft.EntityFrameworkCore.Sqlite`). Add them to the csproj when you introduce the first integration test.
+
+### Shared infrastructure
+
+[IntegrationTestCommon](IntegrationTestCommon/) (`WebApplicationFactoryBase`, `ITestDatabase`, `SqliteTestDatabase`, etc.) is legacy. New tests should set up their own in-process host inline — see [Authoring an application factory](#authoring-an-application-factory) — rather than take a dependency on it. Existing tests that already use it can keep doing so.
+
+---
+
+## Authoring an application factory
+
+Each project under test gets one `FooApplicationFactory` class (named after the system it wraps — `AdminApplicationFactory`, `ApiApplicationFactory`, etc.). It holds a `WebApplicationFactory` configured via `WithWebHostBuilder` — no subclassing needed — and exposes two things to test code:
+
+1. `HttpClient` accessors.
+2. Intent-revealing methods (`RegisterUserAsync`, `LoginAsync`, `AssertOrganizationExistsAsync`, …) that describe what the test wants done.
+
+Avoid exposing `Services`, `Server`, or other DI primitives directly to tests. Intent methods may use DI internally — that's the right place for it. Test code should call intent methods rather than reaching for the host's DI itself.
+
+The DB-replacement shape follows Microsoft's [Customize WebApplicationFactory](https://learn.microsoft.com/en-us/aspnet/core/test/integration-tests#customize-webapplicationfactory) guidance — remove the host's `IDbContextOptionsConfiguration` registration, then call `AddDbContext` with an in-memory SQLite connection.
+
+```csharp
+public sealed class FooApplicationFactory : IAsyncDisposable
+{
+ private readonly SqliteConnection _connection;
+ private readonly WebApplicationFactory _factory;
+
+ public FooApplicationFactory()
+ {
+ _connection = new SqliteConnection("DataSource=:memory:");
+ _connection.Open();
+
+ _factory = new WebApplicationFactory().WithWebHostBuilder(builder =>
+ {
+ builder.ConfigureAppConfiguration((_, config) =>
+ {
+ // Dummy SQLite values — the provider has to be set so the host wires
+ // up EF Core, but the connection string itself is never used because
+ // we replace the DbContext registration in ConfigureServices below.
+ config.AddInMemoryCollection(new Dictionary
+ {
+ ["globalSettings:databaseProvider"] = "sqlite",
+ ["globalSettings:sqlite:connectionString"] = "Data Source=ignored.db",
+ ["globalSettings:redis:connectionString"] = "",
+ });
+ });
+
+ builder.ConfigureServices(services =>
+ {
+ // Replace EF setup with in-memory SQLite — see Microsoft docs above
+ services.RemoveAll>();
+ services.AddDbContext(options => options.UseSqlite(_connection));
+
+ // Substitute services here, but only when running the real thing
+ // would make the test unachievable. Example: mocking IMailService so
+ // a test can capture a verification token from the call args.
+ // services.AddSingleton(Substitute.For());
+ });
+ });
+
+ // Touching Services builds the host; schema then exists for the lifetime
+ // of _connection.
+ using var scope = _factory.Services.CreateScope();
+ scope.ServiceProvider.GetRequiredService().Database.EnsureCreated();
+ }
+
+ public HttpClient CreateClient() => _factory.CreateClient();
+
+ // --- Intent methods ----------------------------------------------------
+
+ public async Task RegisterUserAsync(string email, string password)
+ {
+ // … HTTP requests to /accounts/register/send-verification-email and
+ // /accounts/register/finish, walking the same flow a real client would …
+ }
+
+ public async Task LoginAsync(string email, string password)
+ {
+ // … HTTP request to /connect/token, returns access token …
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _factory.DisposeAsync();
+ _connection.Dispose();
+ }
+}
+```
+
+Key points:
+
+- **The application factory is the test API.** Test code calls `factory.RegisterUserAsync(...)` rather than `factory.Services.GetRequiredService<...>()`. If you find yourself wanting a generic "give me the DbContext" method, that's a smell — name the intent (`RegisterUserAsync`, `MarkUserAsConfirmedAsync`) instead, and drive it through HTTP when practical. When the operation isn't expressible over HTTP, the intent method can use DI internally — just keep the abstraction at the intent-method boundary, not the DI primitives.
+- **`WithWebHostBuilder` returns a configured factory.** Storing it in a field is enough; subclassing `WebApplicationFactory` for tests is rarely necessary.
+- **The `SqliteConnection` is the database.** As long as it stays open, the schema and data persist. Close it and the database is gone — that's how isolation works in `IClassFixture`.
+- **The dummy SQLite config keys are required** because the host's `ConfigureServices` pipeline branches on `globalSettings:databaseProvider` to decide which EF setup to register. Picking `sqlite` lays down EF; we then `RemoveAll` the host's `IDbContextOptionsConfiguration` and re-register with our in-memory connection. The string value is never used.
+- **Keep mocking to a minimum.** The point of an integration test is to run real code through real DI. Substitute a service only when the real implementation would make the test unachievable — it would send real emails to real recipients, charge a real card, push real notifications, or require credentials the test environment doesn't have. Don't reflexively mock something just because it's "external."
+
+### Intent methods and the road to real-instance mode
+
+The application-factory shape is designed so that a second implementation can drive the same intent methods against a real Bitwarden cluster over HTTP. When that happens:
+
+- Methods that already use `HttpClient` internally port for free.
+- Methods that reach into in-process DI need an HTTP equivalent — or, if there's no way to express the operation against a real instance, the real-instance implementation throws a skip exception so the test reports skipped instead of failed (xUnit v3: `Assert.Skip(...)`; xUnit v2 + `xunit.skippablefact`: `throw new SkipException(...)`).
+
+When writing a new intent method, ask whether the operation is expressible against a real instance. If yes, prefer the HTTP shape even in the in-process variant — it costs nothing now and removes work later. If not — and there are real reasons this happens: state the API doesn't let you set, third-party state the host reads back from, a command the user surface doesn't reach — reach into DI inside the intent method and leave a short comment so the gap is visible when the real-instance variant is built. A DI-backed intent method is better than a leaked `Services` accessor.
+
+---
+
+## Writing tests
+
+**Group tests by the scenario being exercised, not by the controller or class being called.** A test class describes a behavior (`RetrievingFooTests`, `UserRegistrationFlowTests`, `BusinessUnitConversionTests`), and its methods are variations on that scenario (happy path, edge cases, failure modes). Mirroring source file structure (`FooControllerTests`) bakes in production-class coupling that's irrelevant to the behavior under test and obscures what's actually being verified.
+
+Use `IClassFixture` so the application factory (and its in-memory database) is constructed once per class, then disposed at the end. Tests interact through `CreateClient()` and the intent methods — never through `Services` or any host primitive:
+
+```csharp
+public class RetrievingFooTests(FooApplicationFactory factory) : IClassFixture
+{
+ [Fact]
+ public async Task AuthenticatedUser_GetsFoo_ReturnsOk()
+ {
+ await factory.RegisterUserAsync("test@example.com", "password");
+ var token = await factory.LoginAsync("test@example.com", "password");
+
+ var client = factory.CreateClient();
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
+
+ var response = await client.GetAsync("/foo");
+ await Assert.SuccessResponseAsync(response);
+
+ var body = await response.Content.ReadFromJsonAsync();
+ Assert.NotNull(body);
+ }
+}
+```
+
+The test body reads as intent — "register a user, log in, retrieve foo, assert." Re-pointed at a real Bitwarden instance, every line still makes sense. That's the bar.
+
+### Request and response models
+
+The example uses an anonymous object for the request body and `JsonObject` for the response. The alternative is the production DTOs (`OrganizationCreateRequest`, etc.) directly:
+
+- **Production DTOs**: typed access, IDE rename, less verbose — especially for deep responses. A wire-shape change made alongside an `[JsonPropertyName]` swap still compiles and passes, so old clients pinned to the previous shape can break silently.
+- **Anonymous objects + `JsonObject`**: wire-shape drift breaks the test, surfacing breakage for old clients. More verbose, especially when reading deep responses.
+
+### Asserting HTTP responses
+
+`HttpResponseMessage.EnsureSuccessStatusCode()` throws with just the status code — no body, no clue what actually went wrong. Use [`Assert.SuccessResponseAsync(response)`](Common/Helpers/AssertExtensions.cs) instead. It's a C# 14 extension on `Assert` that slots into the existing xUnit vocabulary (`Assert.Equal`, `Assert.NotNull`, `Assert.SuccessResponseAsync`) and surfaces the response body — pretty-printed when it's JSON — in the failure message:
+
+```csharp
+var response = await client.GetAsync("/foo");
+await Assert.SuccessResponseAsync(response);
+```
+
+Add a `` to `test/Common/Common.csproj` (`Bit.Test.Common`) when your unit-test project doesn't already have one.
+
+---
+
+## Multi-app fixtures
+
+When one host produces a side effect another host consumes — Admin sends a link the Api side later validates, or an Admin-issued conversion token the Api redeems — compose application factories in a fixture. The fixture owns any shared in-process state (e.g., a shared SQLite connection) and exposes the factories to tests.
+
+```csharp
+public sealed class FooBarFixture : IAsyncDisposable
+{
+ private readonly SqliteConnection _connection;
+ public FooApplicationFactory Foo { get; }
+ public BarApplicationFactory Bar { get; }
+
+ public FooBarFixture()
+ {
+ _connection = new SqliteConnection("DataSource=:memory:");
+ _connection.Open();
+
+ // Internal constructors let the fixture inject the shared connection;
+ // test code can't reach them.
+ Foo = new FooApplicationFactory(_connection, owns: true);
+ Bar = new BarApplicationFactory(_connection, owns: false);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await Foo.DisposeAsync();
+ await Bar.DisposeAsync();
+ _connection.Dispose();
+ }
+}
+```
+
+Rules:
+
+1. **The fixture owns the shared state.** A shared `SqliteConnection` lives on the fixture, not on either application factory. Each factory takes it via an `internal` constructor (or method) so test code can't reach it.
+2. **Only the primary factory runs `EnsureCreated`** (controlled by the `owns: true` flag in the example). Secondaries register `UseSqlite(sharedConnection)` against the same instance and skip schema creation.
+3. **For side-effect capture (e.g., extracting a token from a mocked `IMailService` send)**, do it inside the application factory as an intent method (`factory.NextSentLinkTokenAsync()` or similar). Don't expose `ConcurrentDictionary<…>` on the fixture for tests to poke.
+4. **`IClassFixture` binds the composition to the test class.** xUnit constructs the fixture once for the class, sharing both factories across every test.
+
+For an example of a working multi-app composition (in legacy shape — useful as a reference for the wiring, not the abstraction), see [Billing.IntegrationTest/StripeTestsFixture.cs](Billing.IntegrationTest/StripeTestsFixture.cs) and [AdminApplicationFactory.cs](Billing.IntegrationTest/AdminApplicationFactory.cs).
+
+---
+
+## Anti-patterns
+
+- **Exposing `Services`, `Server`, or `DatabaseContext` on the application factory.** Bypasses the abstraction and couples the test to the in-process host. Add an intent method that names what the test wants done — even if it uses DI internally.
+- **Tests touching `factory.Services` or building their own `WebApplicationFactory`.** The test ends up depending on the host shape rather than the application factory's API. Route through an intent method on the factory instead — wrapping a DI call in a named method is fine; calling DI directly from the test isn't.
+- **Inheriting from `WebApplicationFactoryBase` or using `ITestDatabase`/`SqliteTestDatabase`.** Both belong to the legacy shared infrastructure. Use `WebApplicationFactory` plus `WithWebHostBuilder` directly and set up SQLite inline.
+- **Mutating application-factory state after the host has been built.** `WithWebHostBuilder` is consumed once when the host is created (first `Services` access). Late mutations silently no-op and the bug looks like "my override didn't apply." Set everything in the constructor or fixture.
+- **Sharing one application factory across tests that mutate state, without an `IClassFixture` boundary.** State bleeds between tests in unpredictable order. Either use `IClassFixture` per class, or expose a reset intent method and call it from `IAsyncLifetime.InitializeAsync`.
+- **Closing the SQLite connection before the application factory disposes.** Closing the connection drops the in-memory database immediately; any in-flight request fails. Keep it open for the application factory's full lifetime and let `DisposeAsync` close it.
+- **Naming test classes after controllers** (`FooControllerTests`). Name them after the scenario being tested (`RetrievingFooTests`, `UserRegistrationFlowTests`). Production-class mirroring obscures what behavior the class actually exercises.
+- **Calling `EnsureSuccessStatusCode()`.** The error message contains only the status code — no body, no diagnostic. Use `Assert.SuccessResponseAsync(response)` instead; it includes the (pretty-printed JSON) response body in the failure message so the test tells you what actually broke.
diff --git a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs
index 9a5911d4328c..2271611125db 100644
--- a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs
+++ b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs
@@ -48,6 +48,8 @@ public abstract class WebApplicationFactoryBase : WebApplicationFactory
///
public bool ManagesDatabase { get; set; } = true;
+ public bool StripeEnabled { get; set; } = false;
+
protected readonly List> _configureTestServices = new();
private readonly List> _configureAppConfiguration = new();
@@ -232,9 +234,12 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
services.AddSingleton();
// Noop StripePaymentService - this could be changed to integrate with our Stripe test account
- Replace(services, Substitute.For());
+ if (!StripeEnabled)
+ {
+ Replace(services, Substitute.For());
- Replace(services, Substitute.For());
+ Replace(services, Substitute.For());
+ }
});
foreach (var configureTestService in _configureTestServices)