using System.Net;
using Bit.Api.IntegrationTest.Factories;
using Bit.Api.IntegrationTest.Helpers;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Xunit;
namespace Bit.Api.IntegrationTest.Billing.Controllers;
///
/// Integration tests for PreviewInvoiceController, focusing on the organization-scoped
/// endpoints that take an organizationId in the route.
///
/// Reproduces VULN-501 (PM-34848): the [InjectOrganization] action filter loads an
/// Organization from the route but performs no membership/role check, so any
/// authenticated user could probe billing data for any organization.
///
public class PreviewInvoiceControllerTests : IClassFixture, IAsyncLifetime
{
private readonly HttpClient _client;
private readonly ApiApplicationFactory _factory;
private readonly LoginHelper _loginHelper;
private Organization _organization = null!;
private OrganizationUser _ownerOrgUser = null!;
private string _ownerEmail = null!;
public PreviewInvoiceControllerTests(ApiApplicationFactory factory)
{
_factory = factory;
_client = _factory.CreateClient();
_loginHelper = new LoginHelper(_factory, _client);
}
public async Task InitializeAsync()
{
_ownerEmail = $"preview-invoice-owner-{Guid.NewGuid()}@bitwarden.com";
await _factory.LoginWithNewAccount(_ownerEmail);
(_organization, _ownerOrgUser) = await OrganizationTestHelpers.SignUpAsync(
_factory,
plan: PlanType.Free,
ownerEmail: _ownerEmail);
}
public Task DisposeAsync()
{
_client.Dispose();
return Task.CompletedTask;
}
#region plan-change authorization tests
///
/// Reproduces VULN-501: a fully-unrelated authenticated user calls the plan-change
/// preview endpoint with a victim org's id. Authorization must reject the request
/// before [InjectOrganization] loads the org or the command reaches Stripe.
///
[Fact]
public async Task PreviewPlanChangeTax_AsNonMember_ReturnsForbidden()
{
var attackerEmail = $"preview-invoice-attacker-{Guid.NewGuid()}@bitwarden.com";
await _factory.LoginWithNewAccount(attackerEmail);
await _loginHelper.LoginAsync(attackerEmail);
var response = await _client.PostAsJsonAsync(
$"billing/preview-invoice/organizations/{_organization.Id}/subscription/plan-change",
BuildPlanChangePayload());
Assert.True(
response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized,
$"Expected 401 or 403 but got {(int)response.StatusCode} {response.StatusCode}. " +
"Non-org-members must not be able to preview plan-change tax for an org they don't belong to.");
}
///
/// Cross-org variant: attacker owns their own org but is not a member of the victim's org.
/// Owning a different org must not grant access to the victim's billing data.
///
[Fact]
public async Task PreviewPlanChangeTax_AsOwnerOfDifferentOrg_ReturnsForbidden()
{
var attackerEmail = $"preview-invoice-other-owner-{Guid.NewGuid()}@bitwarden.com";
await _factory.LoginWithNewAccount(attackerEmail);
await OrganizationTestHelpers.SignUpAsync(
_factory,
plan: PlanType.Free,
ownerEmail: attackerEmail,
name: "Attacker Org",
billingEmail: attackerEmail);
await _loginHelper.LoginAsync(attackerEmail);
var response = await _client.PostAsJsonAsync(
$"billing/preview-invoice/organizations/{_organization.Id}/subscription/plan-change",
BuildPlanChangePayload());
Assert.True(
response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized,
$"Expected 401 or 403 but got {(int)response.StatusCode} {response.StatusCode}. " +
"Being an owner of a different org must not grant access to another org's billing data.");
}
///
/// A plain User of the org (not Owner, not Provider) is below the
/// ManageOrganizationBillingRequirement bar and must be rejected.
///
[Fact]
public async Task PreviewPlanChangeTax_AsRegularMember_ReturnsForbidden()
{
var (memberEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
_factory, _organization.Id, OrganizationUserType.User,
permissions: new Permissions());
await _loginHelper.LoginAsync(memberEmail);
var response = await _client.PostAsJsonAsync(
$"billing/preview-invoice/organizations/{_organization.Id}/subscription/plan-change",
BuildPlanChangePayload());
Assert.True(
response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized,
$"Expected 401 or 403 but got {(int)response.StatusCode} {response.StatusCode}. " +
"Regular org members must not be able to preview plan-change tax.");
}
///
/// Positive test: the org Owner satisfies the requirement and must pass authorization.
/// The downstream call may fail (Stripe is not wired up in integration tests),
/// but the response must NOT be 401/403.
///
[Fact]
public async Task PreviewPlanChangeTax_AsOwner_IsNotForbidden()
{
await _loginHelper.LoginAsync(_ownerEmail);
var response = await _client.PostAsJsonAsync(
$"billing/preview-invoice/organizations/{_organization.Id}/subscription/plan-change",
BuildPlanChangePayload());
Assert.True(
response.StatusCode is not HttpStatusCode.Forbidden and not HttpStatusCode.Unauthorized,
$"Expected to pass authorization but got {(int)response.StatusCode} {response.StatusCode}.");
}
#endregion
#region update authorization tests
///
/// Reproduces the second half of VULN-501: the subscription/update preview endpoint
/// is called by a non-member. Even though the endpoint itself returns BadRequest for
/// orgs without an active Stripe subscription, that response leaks subscription
/// status — authorization must reject the request first.
///
[Fact]
public async Task PreviewSubscriptionUpdateTax_AsNonMember_ReturnsForbidden()
{
var attackerEmail = $"preview-update-attacker-{Guid.NewGuid()}@bitwarden.com";
await _factory.LoginWithNewAccount(attackerEmail);
await _loginHelper.LoginAsync(attackerEmail);
var response = await _client.PutAsJsonAsync(
$"billing/preview-invoice/organizations/{_organization.Id}/subscription/update",
BuildUpdatePayload());
Assert.True(
response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized,
$"Expected 401 or 403 but got {(int)response.StatusCode} {response.StatusCode}. " +
"Non-org-members must not be able to preview subscription updates for an org they don't belong to.");
}
[Fact]
public async Task PreviewSubscriptionUpdateTax_AsOwnerOfDifferentOrg_ReturnsForbidden()
{
var attackerEmail = $"preview-update-other-owner-{Guid.NewGuid()}@bitwarden.com";
await _factory.LoginWithNewAccount(attackerEmail);
await OrganizationTestHelpers.SignUpAsync(
_factory,
plan: PlanType.Free,
ownerEmail: attackerEmail,
name: "Attacker Org 2",
billingEmail: attackerEmail);
await _loginHelper.LoginAsync(attackerEmail);
var response = await _client.PutAsJsonAsync(
$"billing/preview-invoice/organizations/{_organization.Id}/subscription/update",
BuildUpdatePayload());
Assert.True(
response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized,
$"Expected 401 or 403 but got {(int)response.StatusCode} {response.StatusCode}. " +
"Being an owner of a different org must not grant access to another org's billing data.");
}
[Fact]
public async Task PreviewSubscriptionUpdateTax_AsRegularMember_ReturnsForbidden()
{
var (memberEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
_factory, _organization.Id, OrganizationUserType.User,
permissions: new Permissions());
await _loginHelper.LoginAsync(memberEmail);
var response = await _client.PutAsJsonAsync(
$"billing/preview-invoice/organizations/{_organization.Id}/subscription/update",
BuildUpdatePayload());
Assert.True(
response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized,
$"Expected 401 or 403 but got {(int)response.StatusCode} {response.StatusCode}. " +
"Regular org members must not be able to preview subscription updates.");
}
[Fact]
public async Task PreviewSubscriptionUpdateTax_AsOwner_IsNotForbidden()
{
await _loginHelper.LoginAsync(_ownerEmail);
var response = await _client.PutAsJsonAsync(
$"billing/preview-invoice/organizations/{_organization.Id}/subscription/update",
BuildUpdatePayload());
Assert.True(
response.StatusCode is not HttpStatusCode.Forbidden and not HttpStatusCode.Unauthorized,
$"Expected to pass authorization but got {(int)response.StatusCode} {response.StatusCode}.");
}
#endregion
private static object BuildPlanChangePayload() => new
{
plan = new { tier = "Teams", cadence = "Annually" },
billingAddress = new { country = "US", postalCode = "10001" }
};
private static object BuildUpdatePayload() => new
{
update = new { passwordManager = new { seats = 10 } }
};
}