From 7c99f9e2570a3c5662fb13203d52ceeb8db52d11 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Wed, 17 Jun 2026 08:54:48 -0400 Subject: [PATCH 1/4] Add General Integration Test Doc --- test/INTEGRATION_TEST.md | 239 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 test/INTEGRATION_TEST.md 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. From 6d1b954e352fbe1b10610d9de439ce6ac1829c19 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Wed, 17 Jun 2026 09:59:46 -0400 Subject: [PATCH 2/4] Add extensive stripe tests --- bitwarden-server.slnx | 1 + .../AddingOrganizationTaxIdTests.cs | 61 + .../AdminApplicationFactory.cs | 124 + .../Billing.IntegrationTest.csproj | 33 + .../BillingApplicationFactory.cs | 126 + .../BillingFactAttribute.cs | 16 + .../BusinessUnitConversionTests.cs | 15 + ...ncellingAndReinstatingSubscriptionTests.cs | 38 + ...CreatingPremiumForExistingCustomerTests.cs | 36 + .../DiscountAudienceFilterTests.cs | 33 + .../IdentityApplicationFactoryExtensions.cs | 36 + .../LegacyBillingFlagsFixture.cs | 30 + .../LegacyBillingFlowsTests.cs | 86 + .../MigrationCohortTests.cs | 52 + .../PersonalDiscountsFixture.cs | 26 + .../PreExistingStateTests.cs | 34 + test/Billing.IntegrationTest/README.md | 51 + .../RetrievingOrganizationBillingTests.cs | 62 + .../RetrievingPremiumSubscriptionTests.cs | 40 + .../RetrievingProviderBillingTests.cs | 53 + .../StripeTestsFixture.cs | 573 +++++ .../StripeWebhookTests.cs | 128 + .../StripeWebhookTestsFixture.cs | 23 + .../SubscriptionPreviewTests.cs | 50 + .../UnpaidCancellationTests.cs | 27 + .../UpdatingOrganizationBillingTests.cs | 34 + .../UpdatingPaymentMethodTests.cs | 36 + .../UpdatingPersonalBillingAddressTests.cs | 36 + .../UpdatingPremiumSubscriptionTests.cs | 49 + ...UpdatingSecretsManagerSubscriptionTests.cs | 29 + .../UpgradingOrganizationPlanTests.cs | 32 + .../packages.lock.json | 2071 +++++++++++++++++ test/Common/Helpers/AssertExtensions.cs | 44 + .../Factories/WebApplicationFactoryBase.cs | 9 +- 34 files changed, 4092 insertions(+), 2 deletions(-) create mode 100644 test/Billing.IntegrationTest/AddingOrganizationTaxIdTests.cs create mode 100644 test/Billing.IntegrationTest/AdminApplicationFactory.cs create mode 100644 test/Billing.IntegrationTest/Billing.IntegrationTest.csproj create mode 100644 test/Billing.IntegrationTest/BillingApplicationFactory.cs create mode 100644 test/Billing.IntegrationTest/BillingFactAttribute.cs create mode 100644 test/Billing.IntegrationTest/BusinessUnitConversionTests.cs create mode 100644 test/Billing.IntegrationTest/CancellingAndReinstatingSubscriptionTests.cs create mode 100644 test/Billing.IntegrationTest/CreatingPremiumForExistingCustomerTests.cs create mode 100644 test/Billing.IntegrationTest/DiscountAudienceFilterTests.cs create mode 100644 test/Billing.IntegrationTest/IdentityApplicationFactoryExtensions.cs create mode 100644 test/Billing.IntegrationTest/LegacyBillingFlagsFixture.cs create mode 100644 test/Billing.IntegrationTest/LegacyBillingFlowsTests.cs create mode 100644 test/Billing.IntegrationTest/MigrationCohortTests.cs create mode 100644 test/Billing.IntegrationTest/PersonalDiscountsFixture.cs create mode 100644 test/Billing.IntegrationTest/PreExistingStateTests.cs create mode 100644 test/Billing.IntegrationTest/README.md create mode 100644 test/Billing.IntegrationTest/RetrievingOrganizationBillingTests.cs create mode 100644 test/Billing.IntegrationTest/RetrievingPremiumSubscriptionTests.cs create mode 100644 test/Billing.IntegrationTest/RetrievingProviderBillingTests.cs create mode 100644 test/Billing.IntegrationTest/StripeTestsFixture.cs create mode 100644 test/Billing.IntegrationTest/StripeWebhookTests.cs create mode 100644 test/Billing.IntegrationTest/StripeWebhookTestsFixture.cs create mode 100644 test/Billing.IntegrationTest/SubscriptionPreviewTests.cs create mode 100644 test/Billing.IntegrationTest/UnpaidCancellationTests.cs create mode 100644 test/Billing.IntegrationTest/UpdatingOrganizationBillingTests.cs create mode 100644 test/Billing.IntegrationTest/UpdatingPaymentMethodTests.cs create mode 100644 test/Billing.IntegrationTest/UpdatingPersonalBillingAddressTests.cs create mode 100644 test/Billing.IntegrationTest/UpdatingPremiumSubscriptionTests.cs create mode 100644 test/Billing.IntegrationTest/UpdatingSecretsManagerSubscriptionTests.cs create mode 100644 test/Billing.IntegrationTest/UpgradingOrganizationPlanTests.cs create mode 100644 test/Billing.IntegrationTest/packages.lock.json create mode 100644 test/Common/Helpers/AssertExtensions.cs 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..13c57e5ad3d7 --- /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..feb40f927b5e --- /dev/null +++ b/test/Billing.IntegrationTest/AdminApplicationFactory.cs @@ -0,0 +1,124 @@ +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 Microsoft.Extensions.DependencyInjection.Extensions; +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..e1bfd5492030 --- /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..99c06ae31f46 --- /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..d0e7735ad4ec --- /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..a8659d8d39ac --- /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..212b51b8d3a0 --- /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..12a627afd5a1 --- /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..3e58756c4f9f --- /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..c628d71e7096 --- /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..59cbe2fbeb49 --- /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..38afe27ed290 --- /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..fc68bb96a773 --- /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..9543fead36e0 --- /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..a581ecef8486 --- /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..38e68f8447dd --- /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..5260e7f65612 --- /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..b093fff12206 --- /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..666421f21f6a --- /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..689cee66b62f --- /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..ec8df1bb2814 --- /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..4d90e3284c82 --- /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..a94742a87518 --- /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..f25c6d08fea2 --- /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/Common/Helpers/AssertExtensions.cs b/test/Common/Helpers/AssertExtensions.cs new file mode 100644 index 000000000000..6b9aeb7ee4e1 --- /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/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) From 7e90d2d9ded6a35b370a6d57c44f36f1259e54cc Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:32:08 -0400 Subject: [PATCH 3/4] Run formatting --- test/Billing.IntegrationTest/AddingOrganizationTaxIdTests.cs | 2 +- test/Billing.IntegrationTest/AdminApplicationFactory.cs | 1 - test/Billing.IntegrationTest/BusinessUnitConversionTests.cs | 2 +- .../CancellingAndReinstatingSubscriptionTests.cs | 2 +- .../CreatingPremiumForExistingCustomerTests.cs | 2 +- test/Billing.IntegrationTest/DiscountAudienceFilterTests.cs | 2 +- test/Billing.IntegrationTest/LegacyBillingFlagsFixture.cs | 2 +- test/Billing.IntegrationTest/LegacyBillingFlowsTests.cs | 2 +- test/Billing.IntegrationTest/MigrationCohortTests.cs | 2 +- test/Billing.IntegrationTest/PersonalDiscountsFixture.cs | 2 +- test/Billing.IntegrationTest/PreExistingStateTests.cs | 2 +- .../RetrievingOrganizationBillingTests.cs | 2 +- .../RetrievingPremiumSubscriptionTests.cs | 2 +- test/Billing.IntegrationTest/RetrievingProviderBillingTests.cs | 2 +- test/Billing.IntegrationTest/StripeWebhookTests.cs | 2 +- test/Billing.IntegrationTest/StripeWebhookTestsFixture.cs | 2 +- test/Billing.IntegrationTest/SubscriptionPreviewTests.cs | 2 +- test/Billing.IntegrationTest/UnpaidCancellationTests.cs | 2 +- .../UpdatingOrganizationBillingTests.cs | 2 +- test/Billing.IntegrationTest/UpdatingPaymentMethodTests.cs | 2 +- .../UpdatingPersonalBillingAddressTests.cs | 2 +- .../UpdatingPremiumSubscriptionTests.cs | 2 +- .../UpdatingSecretsManagerSubscriptionTests.cs | 2 +- test/Billing.IntegrationTest/UpgradingOrganizationPlanTests.cs | 2 +- test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs | 3 +-- test/Common/Helpers/AssertExtensions.cs | 2 +- 26 files changed, 25 insertions(+), 27 deletions(-) diff --git a/test/Billing.IntegrationTest/AddingOrganizationTaxIdTests.cs b/test/Billing.IntegrationTest/AddingOrganizationTaxIdTests.cs index 13c57e5ad3d7..9a09611d0aa5 100644 --- a/test/Billing.IntegrationTest/AddingOrganizationTaxIdTests.cs +++ b/test/Billing.IntegrationTest/AddingOrganizationTaxIdTests.cs @@ -1,4 +1,4 @@ -using System.Net.Http.Json; +using System.Net.Http.Json; using System.Text.Json.Nodes; using Bit.Test.Common.Helpers; diff --git a/test/Billing.IntegrationTest/AdminApplicationFactory.cs b/test/Billing.IntegrationTest/AdminApplicationFactory.cs index feb40f927b5e..75b3e1dde80b 100644 --- a/test/Billing.IntegrationTest/AdminApplicationFactory.cs +++ b/test/Billing.IntegrationTest/AdminApplicationFactory.cs @@ -7,7 +7,6 @@ using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; using NSubstitute; namespace Bit.Billing.IntegrationTest; diff --git a/test/Billing.IntegrationTest/BusinessUnitConversionTests.cs b/test/Billing.IntegrationTest/BusinessUnitConversionTests.cs index e1bfd5492030..7a168fb322e6 100644 --- a/test/Billing.IntegrationTest/BusinessUnitConversionTests.cs +++ b/test/Billing.IntegrationTest/BusinessUnitConversionTests.cs @@ -1,4 +1,4 @@ -using Bit.Test.Common.Helpers; +using Bit.Test.Common.Helpers; namespace Bit.Billing.IntegrationTest; diff --git a/test/Billing.IntegrationTest/CancellingAndReinstatingSubscriptionTests.cs b/test/Billing.IntegrationTest/CancellingAndReinstatingSubscriptionTests.cs index 99c06ae31f46..a64858ec9e96 100644 --- a/test/Billing.IntegrationTest/CancellingAndReinstatingSubscriptionTests.cs +++ b/test/Billing.IntegrationTest/CancellingAndReinstatingSubscriptionTests.cs @@ -1,4 +1,4 @@ -using System.Net.Http.Json; +using System.Net.Http.Json; using Bit.Test.Common.Helpers; namespace Bit.Billing.IntegrationTest; diff --git a/test/Billing.IntegrationTest/CreatingPremiumForExistingCustomerTests.cs b/test/Billing.IntegrationTest/CreatingPremiumForExistingCustomerTests.cs index d0e7735ad4ec..6ef783f4a793 100644 --- a/test/Billing.IntegrationTest/CreatingPremiumForExistingCustomerTests.cs +++ b/test/Billing.IntegrationTest/CreatingPremiumForExistingCustomerTests.cs @@ -1,4 +1,4 @@ -using System.Net.Http.Json; +using System.Net.Http.Json; using System.Text.Json.Nodes; using Bit.Test.Common.Helpers; diff --git a/test/Billing.IntegrationTest/DiscountAudienceFilterTests.cs b/test/Billing.IntegrationTest/DiscountAudienceFilterTests.cs index a8659d8d39ac..c65d40ee5323 100644 --- a/test/Billing.IntegrationTest/DiscountAudienceFilterTests.cs +++ b/test/Billing.IntegrationTest/DiscountAudienceFilterTests.cs @@ -1,4 +1,4 @@ -using System.Net.Http.Headers; +using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json.Nodes; using Bit.Test.Common.Helpers; diff --git a/test/Billing.IntegrationTest/LegacyBillingFlagsFixture.cs b/test/Billing.IntegrationTest/LegacyBillingFlagsFixture.cs index 212b51b8d3a0..574431ecc339 100644 --- a/test/Billing.IntegrationTest/LegacyBillingFlagsFixture.cs +++ b/test/Billing.IntegrationTest/LegacyBillingFlagsFixture.cs @@ -1,4 +1,4 @@ -using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Factories; using Bit.Core; namespace Bit.Billing.IntegrationTest; diff --git a/test/Billing.IntegrationTest/LegacyBillingFlowsTests.cs b/test/Billing.IntegrationTest/LegacyBillingFlowsTests.cs index 12a627afd5a1..2d5082b67cd2 100644 --- a/test/Billing.IntegrationTest/LegacyBillingFlowsTests.cs +++ b/test/Billing.IntegrationTest/LegacyBillingFlowsTests.cs @@ -1,4 +1,4 @@ -using System.Net.Http.Json; +using System.Net.Http.Json; using Bit.Core.Billing.Enums; using Bit.Test.Common.Helpers; diff --git a/test/Billing.IntegrationTest/MigrationCohortTests.cs b/test/Billing.IntegrationTest/MigrationCohortTests.cs index 3e58756c4f9f..730da7c8bccc 100644 --- a/test/Billing.IntegrationTest/MigrationCohortTests.cs +++ b/test/Billing.IntegrationTest/MigrationCohortTests.cs @@ -1,4 +1,4 @@ -using Bit.Core.Billing.Organizations.PlanMigration.Enums; +using Bit.Core.Billing.Organizations.PlanMigration.Enums; using Bit.Test.Common.Helpers; namespace Bit.Billing.IntegrationTest; diff --git a/test/Billing.IntegrationTest/PersonalDiscountsFixture.cs b/test/Billing.IntegrationTest/PersonalDiscountsFixture.cs index c628d71e7096..739d78fa8c42 100644 --- a/test/Billing.IntegrationTest/PersonalDiscountsFixture.cs +++ b/test/Billing.IntegrationTest/PersonalDiscountsFixture.cs @@ -1,4 +1,4 @@ -using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Factories; using Bit.Core; namespace Bit.Billing.IntegrationTest; diff --git a/test/Billing.IntegrationTest/PreExistingStateTests.cs b/test/Billing.IntegrationTest/PreExistingStateTests.cs index 59cbe2fbeb49..89cac5bf4f51 100644 --- a/test/Billing.IntegrationTest/PreExistingStateTests.cs +++ b/test/Billing.IntegrationTest/PreExistingStateTests.cs @@ -1,4 +1,4 @@ -using Bit.Test.Common.Helpers; +using Bit.Test.Common.Helpers; namespace Bit.Billing.IntegrationTest; diff --git a/test/Billing.IntegrationTest/RetrievingOrganizationBillingTests.cs b/test/Billing.IntegrationTest/RetrievingOrganizationBillingTests.cs index 38afe27ed290..f650750d3d29 100644 --- a/test/Billing.IntegrationTest/RetrievingOrganizationBillingTests.cs +++ b/test/Billing.IntegrationTest/RetrievingOrganizationBillingTests.cs @@ -1,4 +1,4 @@ -using System.Net.Http.Json; +using System.Net.Http.Json; using System.Text.Json.Nodes; using Bit.Test.Common.Helpers; diff --git a/test/Billing.IntegrationTest/RetrievingPremiumSubscriptionTests.cs b/test/Billing.IntegrationTest/RetrievingPremiumSubscriptionTests.cs index fc68bb96a773..e2ea3c8e9364 100644 --- a/test/Billing.IntegrationTest/RetrievingPremiumSubscriptionTests.cs +++ b/test/Billing.IntegrationTest/RetrievingPremiumSubscriptionTests.cs @@ -1,4 +1,4 @@ -using System.Net.Http.Json; +using System.Net.Http.Json; using System.Text.Json.Nodes; using Bit.Test.Common.Helpers; diff --git a/test/Billing.IntegrationTest/RetrievingProviderBillingTests.cs b/test/Billing.IntegrationTest/RetrievingProviderBillingTests.cs index 9543fead36e0..d4cc4cdc221d 100644 --- a/test/Billing.IntegrationTest/RetrievingProviderBillingTests.cs +++ b/test/Billing.IntegrationTest/RetrievingProviderBillingTests.cs @@ -1,4 +1,4 @@ -using System.Net.Http.Json; +using System.Net.Http.Json; using System.Text.Json.Nodes; using Bit.Test.Common.Helpers; diff --git a/test/Billing.IntegrationTest/StripeWebhookTests.cs b/test/Billing.IntegrationTest/StripeWebhookTests.cs index a581ecef8486..9cb90995a767 100644 --- a/test/Billing.IntegrationTest/StripeWebhookTests.cs +++ b/test/Billing.IntegrationTest/StripeWebhookTests.cs @@ -1,4 +1,4 @@ -using System.Text.Json.Nodes; +using System.Text.Json.Nodes; namespace Bit.Billing.IntegrationTest; diff --git a/test/Billing.IntegrationTest/StripeWebhookTestsFixture.cs b/test/Billing.IntegrationTest/StripeWebhookTestsFixture.cs index 38e68f8447dd..7b931922e1d8 100644 --- a/test/Billing.IntegrationTest/StripeWebhookTestsFixture.cs +++ b/test/Billing.IntegrationTest/StripeWebhookTestsFixture.cs @@ -1,4 +1,4 @@ -namespace Bit.Billing.IntegrationTest; +namespace Bit.Billing.IntegrationTest; /// /// Variant of that additionally spins up the diff --git a/test/Billing.IntegrationTest/SubscriptionPreviewTests.cs b/test/Billing.IntegrationTest/SubscriptionPreviewTests.cs index 5260e7f65612..e15b5d0e10f1 100644 --- a/test/Billing.IntegrationTest/SubscriptionPreviewTests.cs +++ b/test/Billing.IntegrationTest/SubscriptionPreviewTests.cs @@ -1,4 +1,4 @@ -using System.Net.Http.Json; +using System.Net.Http.Json; using System.Text.Json.Nodes; using Bit.Test.Common.Helpers; diff --git a/test/Billing.IntegrationTest/UnpaidCancellationTests.cs b/test/Billing.IntegrationTest/UnpaidCancellationTests.cs index b093fff12206..2bf649bf2200 100644 --- a/test/Billing.IntegrationTest/UnpaidCancellationTests.cs +++ b/test/Billing.IntegrationTest/UnpaidCancellationTests.cs @@ -1,4 +1,4 @@ -namespace Bit.Billing.IntegrationTest; +namespace Bit.Billing.IntegrationTest; public class UnpaidCancellationTests(StripeTestsFixture fixture) : IClassFixture { diff --git a/test/Billing.IntegrationTest/UpdatingOrganizationBillingTests.cs b/test/Billing.IntegrationTest/UpdatingOrganizationBillingTests.cs index 666421f21f6a..0f545cbabc89 100644 --- a/test/Billing.IntegrationTest/UpdatingOrganizationBillingTests.cs +++ b/test/Billing.IntegrationTest/UpdatingOrganizationBillingTests.cs @@ -1,4 +1,4 @@ -using System.Net.Http.Json; +using System.Net.Http.Json; using System.Text.Json.Nodes; using Bit.Test.Common.Helpers; diff --git a/test/Billing.IntegrationTest/UpdatingPaymentMethodTests.cs b/test/Billing.IntegrationTest/UpdatingPaymentMethodTests.cs index 689cee66b62f..f50823f1f701 100644 --- a/test/Billing.IntegrationTest/UpdatingPaymentMethodTests.cs +++ b/test/Billing.IntegrationTest/UpdatingPaymentMethodTests.cs @@ -1,4 +1,4 @@ -using System.Net.Http.Json; +using System.Net.Http.Json; using System.Text.Json.Nodes; using Bit.Test.Common.Helpers; diff --git a/test/Billing.IntegrationTest/UpdatingPersonalBillingAddressTests.cs b/test/Billing.IntegrationTest/UpdatingPersonalBillingAddressTests.cs index ec8df1bb2814..a88883151757 100644 --- a/test/Billing.IntegrationTest/UpdatingPersonalBillingAddressTests.cs +++ b/test/Billing.IntegrationTest/UpdatingPersonalBillingAddressTests.cs @@ -1,4 +1,4 @@ -using System.Net.Http.Json; +using System.Net.Http.Json; using System.Text.Json.Nodes; using Bit.Core.Billing.Enums; using Bit.Test.Common.Helpers; diff --git a/test/Billing.IntegrationTest/UpdatingPremiumSubscriptionTests.cs b/test/Billing.IntegrationTest/UpdatingPremiumSubscriptionTests.cs index 4d90e3284c82..4d2d3222cf54 100644 --- a/test/Billing.IntegrationTest/UpdatingPremiumSubscriptionTests.cs +++ b/test/Billing.IntegrationTest/UpdatingPremiumSubscriptionTests.cs @@ -1,4 +1,4 @@ -using System.Net.Http.Json; +using System.Net.Http.Json; using System.Text.Json.Nodes; using Bit.Test.Common.Helpers; diff --git a/test/Billing.IntegrationTest/UpdatingSecretsManagerSubscriptionTests.cs b/test/Billing.IntegrationTest/UpdatingSecretsManagerSubscriptionTests.cs index a94742a87518..00006bcc8450 100644 --- a/test/Billing.IntegrationTest/UpdatingSecretsManagerSubscriptionTests.cs +++ b/test/Billing.IntegrationTest/UpdatingSecretsManagerSubscriptionTests.cs @@ -1,4 +1,4 @@ -using System.Net.Http.Json; +using System.Net.Http.Json; using Bit.Test.Common.Helpers; namespace Bit.Billing.IntegrationTest; diff --git a/test/Billing.IntegrationTest/UpgradingOrganizationPlanTests.cs b/test/Billing.IntegrationTest/UpgradingOrganizationPlanTests.cs index f25c6d08fea2..10731adbc807 100644 --- a/test/Billing.IntegrationTest/UpgradingOrganizationPlanTests.cs +++ b/test/Billing.IntegrationTest/UpgradingOrganizationPlanTests.cs @@ -1,4 +1,4 @@ -using System.Net.Http.Json; +using System.Net.Http.Json; using Bit.Core.Billing.Enums; using Bit.Test.Common.Helpers; 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 index 6b9aeb7ee4e1..ede4656b4cca 100644 --- a/test/Common/Helpers/AssertExtensions.cs +++ b/test/Common/Helpers/AssertExtensions.cs @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable using System.Text.Json; using Xunit; From 10a159f2b4263b1c968d46b7a0f68e2853769d38 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:58:21 -0400 Subject: [PATCH 4/4] Add Billing as owners --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) 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