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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions bitwarden-server.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
<Project Path="test/Admin.Test/Admin.Test.csproj" />
<Project Path="test/Api.IntegrationTest/Api.IntegrationTest.csproj" />
<Project Path="test/Api.Test/Api.Test.csproj" />
<Project Path="test/Billing.IntegrationTest/Billing.IntegrationTest.csproj" />
<Project Path="test/Billing.Test/Billing.Test.csproj" />
<Project Path="test/Common/Common.csproj" />
<Project Path="test/Core.IntegrationTest/Core.IntegrationTest.csproj" />
Expand Down
61 changes: 61 additions & 0 deletions test/Billing.IntegrationTest/AddingOrganizationTaxIdTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using System.Net.Http.Json;
using System.Text.Json.Nodes;
using Bit.Test.Common.Helpers;

namespace Bit.Billing.IntegrationTest;

public class AddingOrganizationTaxIdTests(StripeTestsFixture fixture) : IClassFixture<StripeTestsFixture>
{
[BillingFact]
public async Task BillingAddress_WhenTaxIdProvided_PersistsAndEchoesTaxId()
{
var (client, _, organizationId, _) =
await fixture.PrepareOrganizationOwnerAsync("update-billing-address-tax-id@example.com");

// Drives UpdateBillingAddressCommand.UpdateBusinessBillingAddressAsync, which expands
// tax_ids on the customer update so the existing-tax-id deletion loop can run, then
// CreateTaxIdAsync attaches the new id and the response carries it back.
var response = await client.PutAsJsonAsync(
$"/organizations/{organizationId}/billing/vnext/address",
new
{
Country = "US",
PostalCode = "10001",
Line1 = "123 Test St",
City = "New York",
State = "NY",
TaxId = new { Code = "us_ein", Value = "12-3456789" },
});
await Assert.SuccessResponseAsync(response);

var billingAddress = (await response.Content.ReadFromJsonAsync<JsonObject>())!;
Assert.Equal("us_ein", billingAddress["taxId"]!["code"]!.GetValue<string>());
Assert.Equal("12-3456789", billingAddress["taxId"]!["value"]!.GetValue<string>());
}

[BillingFact]
public async Task BillingAddress_AfterTaxIdAdded_GetAddressReturnsTheTaxId()
{
var (client, _, organizationId, _) =
await fixture.PrepareOrganizationOwnerAsync("get-billing-address-tax-id@example.com");

var updateResponse = await client.PutAsJsonAsync(
$"/organizations/{organizationId}/billing/vnext/address",
new
{
Country = "US",
PostalCode = "10001",
TaxId = new { Code = "us_ein", Value = "12-3456789" },
});
await Assert.SuccessResponseAsync(updateResponse);

// Drives GetBillingAddressQuery.Run business path (Expand=tax_ids), which reads
// customer.TaxIds.FirstOrDefault() to populate the response.
var getResponse = await client.GetAsync($"/organizations/{organizationId}/billing/vnext/address");
await Assert.SuccessResponseAsync(getResponse);

var billingAddress = (await getResponse.Content.ReadFromJsonAsync<JsonObject>())!;
Assert.Equal("us_ein", billingAddress["taxId"]!["code"]!.GetValue<string>());
Assert.Equal("12-3456789", billingAddress["taxId"]!["value"]!.GetValue<string>());
}
}
123 changes: 123 additions & 0 deletions test/Billing.IntegrationTest/AdminApplicationFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
using System.Web;
using Bit.Admin.Jobs;
using Bit.Core.Services;
using Bit.IntegrationTestCommon;
using Bit.Test.Common.Helpers;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;

namespace Bit.Billing.IntegrationTest;

/// <summary>
/// Application factory for the Admin host. Holds an inner
/// <see cref="WebApplicationFactory{TEntryPoint}"/> privately and exposes intent
/// methods for the side-effects (passwordless sign-in tokens, business-unit
/// conversion invite tokens) that would otherwise leave the process as emails.
/// Tokens are recovered after each request by inspecting the substituted
/// <see cref="IMailService"/>'s recorded calls — no captured state on the factory.
/// </summary>
public sealed class AdminApplicationFactory : IAsyncDisposable
{
private readonly WebApplicationFactory<Admin.Program> _factory;

public AdminApplicationFactory(ITestDatabase testDatabase)
{
_factory = new WebApplicationFactory<Admin.Program>().WithWebHostBuilder(builder =>
Comment thread
justindbaur marked this conversation as resolved.
Dismissed
{
builder.ConfigureAppConfiguration((_, config) =>
{
var configValues = new Dictionary<string, string?>();
testDatabase.ModifyGlobalSettings(configValues);
config.AddInMemoryCollection(configValues);
});

builder.ConfigureServices(services =>
{
services.AddSingleton(Substitute.For<IMailService>());

// Remove Quartz hosted jobs to avoid concurrent startup issues
var jobHostedServiceDescriptor = services.Single(sd => sd.ImplementationType == typeof(JobsHostedService));
services.Remove(jobHostedServiceDescriptor);

// Turn off antiforgery application-wide so tests don't have to
// mint or thread CSRF tokens through every form post.
services.PostConfigure<MvcOptions>(options =>
{
options.Filters.Add(new IgnoreAntiforgeryTokenAttribute { Order = 1001 });
});

testDatabase.AddDatabase(services);
});
});
}

/// <summary>
/// Signs into the Admin Portal using the passwordless flow and returns a
/// client whose cookies carry an authenticated admin session.
/// </summary>
public async Task<HttpClient> SignInAdminAsync()
{
const string Email = "admin@localhost";
var client = _factory.CreateClient();
var mailService = _factory.Services.GetRequiredService<IMailService>();

var loginResponse = await client.PostAsync("/login", new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "Email", Email },
}));
Comment thread
justindbaur marked this conversation as resolved.
Dismissed
await Assert.SuccessResponseAsync(loginResponse);

var token = mailService.ReceivedCalls()
.Where(c => c.GetMethodInfo().Name == nameof(IMailService.SendPasswordlessSignInAsync))
.Select(c => (string?)c.GetArguments()[1])
.LastOrDefault();

if (string.IsNullOrEmpty(token))
{
Assert.Fail($"Admin sign-in token was not captured for {Email}.");
}

var confirmResponse = await client.GetAsync(
$"/login/confirm?email={HttpUtility.UrlEncode(Email)}&token={HttpUtility.UrlEncode(token)}&returnUrl=%2F");
await Assert.SuccessResponseAsync(confirmResponse);

return client;
}

/// <summary>
/// Posts to the Admin business-unit conversion endpoint for the given
/// organization and returns the invitation token that would otherwise have
/// been emailed to the provider admin.
/// </summary>
public async Task<string> InitializeBusinessUnitConversionAsync(
HttpClient adminSession, Guid organizationId, string providerAdminEmail)
{
var mailService = _factory.Services.GetRequiredService<IMailService>();

var response = await adminSession.PostAsync(
$"/organizations/billing/{organizationId}/business-unit",
new FormUrlEncodedContent(new Dictionary<string, string?>
{
{ "ProviderAdminEmail", providerAdminEmail },
}));
Comment thread
justindbaur marked this conversation as resolved.
Dismissed
await Assert.SuccessResponseAsync(response);

var token = mailService.ReceivedCalls()
.Where(c => c.GetMethodInfo().Name == nameof(IMailService.SendBusinessUnitConversionInviteAsync))
.Where(c => (string?)c.GetArguments()[2] == providerAdminEmail)
.Select(c => (string?)c.GetArguments()[1])
.LastOrDefault();

if (string.IsNullOrEmpty(token))
{
Assert.Fail($"No business-unit conversion token captured for {providerAdminEmail}.");
}

return token;
}

public ValueTask DisposeAsync() => _factory.DisposeAsync();
}
33 changes: 33 additions & 0 deletions test/Billing.IntegrationTest/Billing.IntegrationTest.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" />
<PackageReference Include="xunit" Version="$(XUnitVersion)" />
<PackageReference Include="xunit.runner.visualstudio" Version="$(XUnitRunnerVisualStudioVersion)">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="$(CoverletCollectorVersion)">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\Admin\Admin.csproj" />
<ProjectReference Include="..\..\src\Billing\Billing.csproj" />
<ProjectReference Include="..\Api.IntegrationTest\Api.IntegrationTest.csproj" />
<ProjectReference Include="..\Common\Common.csproj" />
</ItemGroup>

</Project>
126 changes: 126 additions & 0 deletions test/Billing.IntegrationTest/BillingApplicationFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
using Bit.IntegrationTestCommon;
using Bit.Test.Common.Helpers;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;

namespace Bit.Billing.IntegrationTest;

/// <summary>
/// Application factory for the Bit.Billing webhook host. Holds an inner
/// <see cref="WebApplicationFactory{TEntryPoint}"/> privately, shares the
/// API host's database, and exposes a single intent method
/// (<see cref="SendStripeWebhookAsync"/>) that builds a valid signed Stripe
/// event payload and posts it to the controller — tests interact only through
/// that method.
/// </summary>
public sealed class BillingApplicationFactory : IAsyncDisposable
{
public const string WebhookKey = "test-webhook-key";
public const string WebhookSecret = "whsec_billing_integration_test_secret_value";

/// <summary>
/// Matches the API version the SDK is pinned to (see
/// <see cref="Stripe.StripeConfiguration.ApiVersion"/>). The webhook controller
/// rejects events whose <c>api_version</c> doesn't match this value.
/// </summary>
public static string SupportedStripeApiVersion => Stripe.StripeConfiguration.ApiVersion;

private readonly WebApplicationFactory<Bit.Billing.Program> _factory;

public BillingApplicationFactory(ITestDatabase testDatabase)
{
_factory = new WebApplicationFactory<Bit.Billing.Program>().WithWebHostBuilder(builder =>
Comment thread
justindbaur marked this conversation as resolved.
Dismissed
{
builder.UseEnvironment(Environments.Development);

builder.ConfigureAppConfiguration((_, config) =>
{
var configValues = new Dictionary<string, string?>
{
["BillingSettings:StripeWebhookKey"] = WebhookKey,
["BillingSettings:StripeWebhookSecret20250827Basil"] = WebhookSecret,
};
testDatabase.ModifyGlobalSettings(configValues);
config.AddInMemoryCollection(configValues);
});

builder.ConfigureServices(services =>
{
// Drop the Quartz-backed jobs hosted service — it spins up a scheduler we
// don't need (and that throws under parallel test execution).
var jobsHostedService = services.FirstOrDefault(sd =>
sd.ServiceType == typeof(IHostedService)
&& sd.ImplementationType?.FullName == "Bit.Billing.Jobs.JobsHostedService");
if (jobsHostedService != null)
{
services.Remove(jobsHostedService);
}

services.RemoveAll<Quartz.IScheduler>();

testDatabase.AddDatabase(services);
});
});
}

/// <summary>
/// Builds a Stripe-signed event with the given <paramref name="eventType"/> and
/// <paramref name="dataObject"/>, posts it to <c>POST /stripe/webhook</c>, and
/// asserts a successful response. The handler for the event type re-fetches the
/// underlying object from Stripe with the production Expand list — that fetch is
/// what the test exercises.
/// </summary>
public async Task SendStripeWebhookAsync(string eventType, JsonObject dataObject, string eventId)
{
var payload = new JsonObject
{
["id"] = eventId,
["object"] = "event",
["api_version"] = SupportedStripeApiVersion,
["created"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
["livemode"] = false,
["pending_webhooks"] = 0,
["type"] = eventType,
["data"] = new JsonObject
{
["object"] = dataObject,
},
["request"] = new JsonObject
{
["id"] = $"req_{Guid.NewGuid():N}",
["idempotency_key"] = null,
},
};

var json = payload.ToJsonString();
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var signature = ComputeStripeSignature(timestamp, json, WebhookSecret);

using var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, $"/stripe/webhook?key={WebhookKey}")
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
};
Comment thread
justindbaur marked this conversation as resolved.
Dismissed
request.Headers.TryAddWithoutValidation("Stripe-Signature", $"t={timestamp},v1={signature}");

var response = await client.SendAsync(request);
await Assert.SuccessResponseAsync(response);
}

public ValueTask DisposeAsync() => _factory.DisposeAsync();

private static string ComputeStripeSignature(long timestamp, string payload, string secret)
{
// Stripe webhook signature scheme (v1): HMAC-SHA256(secret, "{timestamp}.{payload}") as lowercase hex.
var signedPayload = $"{timestamp}.{payload}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(signedPayload));
return Convert.ToHexStringLower(hash);
}
}
16 changes: 16 additions & 0 deletions test/Billing.IntegrationTest/BillingFactAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace Bit.Billing.IntegrationTest;

/// <summary>
/// A fact that runs only when <c>RUN_STRIPE_INTEGRATION_TESTS</c> is set in the environment.
/// These tests hit a real Stripe account, so they are opt-in to avoid running in CI.
/// </summary>
public sealed class BillingFactAttribute : FactAttribute
{
public BillingFactAttribute()
{
if (Environment.GetEnvironmentVariable("RUN_STRIPE_INTEGRATION_TESTS") is null)
{
Skip = "Manual only — set RUN_STRIPE_INTEGRATION_TESTS to run.";
}
}
}
15 changes: 15 additions & 0 deletions test/Billing.IntegrationTest/BusinessUnitConversionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using Bit.Test.Common.Helpers;

namespace Bit.Billing.IntegrationTest;

public class BusinessUnitConversionTests(StripeTestsFixture fixture) : IClassFixture<StripeTestsFixture>
{
[BillingFact]
public async Task ConvertingOrganizationToBusinessUnit_ProviderWarnings_Succeed()
{
var (client, providerId) = await fixture.PrepareProviderAdminAsync("provider@example.com");

var warningsResponse = await client.GetAsync($"/providers/{providerId}/billing/vnext/warnings");
await Assert.SuccessResponseAsync(warningsResponse);
}
}
Loading
Loading