1
0
mirror of https://github.com/bitwarden/server synced 2025-12-21 10:43:44 +00:00
Files
server/test/Billing.Test/Controllers/BitPayControllerTests.cs
Kyle Denney 99e1326039 [PM-24616] refactor stripe adapter (#6527)
* move billing services+tests to billing namespaces

* reorganized methods in file and added comment headers

* renamed StripeAdapter methods for better clarity

* clean up redundant qualifiers

* Upgrade Stripe.net to v48.4.0

* Update PreviewTaxAmountCommand

* Remove unused UpcomingInvoiceOptionExtensions

* Added SubscriptionExtensions with GetCurrentPeriodEnd

* Update PremiumUserBillingService

* Update OrganizationBillingService

* Update GetOrganizationWarningsQuery

* Update BillingHistoryInfo

* Update SubscriptionInfo

* Remove unused Sql Billing folder

* Update StripeAdapter

* Update StripePaymentService

* Update InvoiceCreatedHandler

* Update PaymentFailedHandler

* Update PaymentSucceededHandler

* Update ProviderEventService

* Update StripeEventUtilityService

* Update SubscriptionDeletedHandler

* Update SubscriptionUpdatedHandler

* Update UpcomingInvoiceHandler

* Update ProviderSubscriptionResponse

* Remove unused Stripe Subscriptions Admin Tool

* Update RemoveOrganizationFromProviderCommand

* Update ProviderBillingService

* Update RemoveOrganizatinoFromProviderCommandTests

* Update PreviewTaxAmountCommandTests

* Update GetCloudOrganizationLicenseQueryTests

* Update GetOrganizationWarningsQueryTests

* Update StripePaymentServiceTests

* Update ProviderBillingControllerTests

* Update ProviderEventServiceTests

* Update SubscriptionDeletedHandlerTests

* Update SubscriptionUpdatedHandlerTests

* Resolve Billing test failures

I completely removed tests for the StripeEventService as they were using a system I setup a while back that read JSON files of the Stripe event structure. I did not anticipate how frequently these structures would change with each API version and the cost of trying to update these specific JSON files to test a very static data retrieval service far outweigh the benefit.

* Resolve Core test failures

* Run dotnet format

* Remove unused provider migration

* Fixed failing tests

* Run dotnet format

* Replace the old webhook secret key with new one (#6223)

* Fix compilation failures in additions

* Run dotnet format

* Bump Stripe API version

* Fix recent addition: CreatePremiumCloudHostedSubscriptionCommand

* Fix new code in main according to Stripe update

* Fix InvoiceExtensions

* Bump SDK version to match API Version

* cleanup

* fixing items missed after the merge

* use expression body for all simple returns

* forgot fixes, format, and pr feedback

* claude pr feedback

* pr feedback and cleanup

* more claude feedback

---------

Co-authored-by: Alex Morask <amorask@bitwarden.com>
Co-authored-by: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com>
2025-12-12 15:32:43 -06:00

392 lines
15 KiB
C#

using Bit.Billing.Controllers;
using Bit.Billing.Models;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Payment.Clients;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using BitPayLight.Models.Invoice;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
using Transaction = Bit.Core.Entities.Transaction;
namespace Bit.Billing.Test.Controllers;
using static BitPayConstants;
public class BitPayControllerTests
{
private readonly GlobalSettings _globalSettings = new();
private readonly IBitPayClient _bitPayClient = Substitute.For<IBitPayClient>();
private readonly ITransactionRepository _transactionRepository = Substitute.For<ITransactionRepository>();
private readonly IOrganizationRepository _organizationRepository = Substitute.For<IOrganizationRepository>();
private readonly IUserRepository _userRepository = Substitute.For<IUserRepository>();
private readonly IProviderRepository _providerRepository = Substitute.For<IProviderRepository>();
private readonly IMailService _mailService = Substitute.For<IMailService>();
private readonly IStripePaymentService _paymentService = Substitute.For<IStripePaymentService>();
private readonly IPremiumUserBillingService _premiumUserBillingService =
Substitute.For<IPremiumUserBillingService>();
private const string _validWebhookKey = "valid-webhook-key";
private const string _invalidWebhookKey = "invalid-webhook-key";
public BitPayControllerTests()
{
var bitPaySettings = new GlobalSettings.BitPaySettings { WebhookKey = _validWebhookKey };
_globalSettings.BitPay = bitPaySettings;
}
private BitPayController CreateController() => new(
_globalSettings,
_bitPayClient,
_transactionRepository,
_organizationRepository,
_userRepository,
_providerRepository,
_mailService,
_paymentService,
Substitute.For<ILogger<BitPayController>>(),
_premiumUserBillingService);
[Fact]
public async Task PostIpn_InvalidKey_BadRequest()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
var result = await controller.PostIpn(eventModel, _invalidWebhookKey);
var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
Assert.Equal("Invalid key", badRequestResult.Value);
}
[Fact]
public async Task PostIpn_NullKey_ThrowsException()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
await Assert.ThrowsAsync<ArgumentNullException>(() => controller.PostIpn(eventModel, null!));
}
[Fact]
public async Task PostIpn_EmptyKey_BadRequest()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
var result = await controller.PostIpn(eventModel, string.Empty);
var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
Assert.Equal("Invalid key", badRequestResult.Value);
}
[Fact]
public async Task PostIpn_NonUsdCurrency_BadRequest()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
var invoice = CreateValidInvoice(currency: "EUR");
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
var result = await controller.PostIpn(eventModel, _validWebhookKey);
var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
Assert.Equal("Cannot process non-USD payments", badRequestResult.Value);
}
[Fact]
public async Task PostIpn_NullPosData_BadRequest()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
var invoice = CreateValidInvoice(posData: null!);
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
var result = await controller.PostIpn(eventModel, _validWebhookKey);
var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
Assert.Equal("Invalid POS data", badRequestResult.Value);
}
[Fact]
public async Task PostIpn_EmptyPosData_BadRequest()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
var invoice = CreateValidInvoice(posData: "");
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
var result = await controller.PostIpn(eventModel, _validWebhookKey);
var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
Assert.Equal("Invalid POS data", badRequestResult.Value);
}
[Fact]
public async Task PostIpn_PosDataWithoutAccountCredit_BadRequest()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
var invoice = CreateValidInvoice(posData: "organizationId:550e8400-e29b-41d4-a716-446655440000");
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
var result = await controller.PostIpn(eventModel, _validWebhookKey);
var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
Assert.Equal("Invalid POS data", badRequestResult.Value);
}
[Fact]
public async Task PostIpn_PosDataWithoutValidId_BadRequest()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
var invoice = CreateValidInvoice(posData: PosDataKeys.AccountCredit);
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
var result = await controller.PostIpn(eventModel, _validWebhookKey);
var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
Assert.Equal("Invalid POS data", badRequestResult.Value);
}
[Fact]
public async Task PostIpn_IncompleteInvoice_Ok()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
var invoice = CreateValidInvoice(status: "paid");
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
var result = await controller.PostIpn(eventModel, _validWebhookKey);
var okResult = Assert.IsType<OkObjectResult>(result);
Assert.Equal("Waiting for invoice to be completed", okResult.Value);
}
[Fact]
public async Task PostIpn_ExistingTransaction_Ok()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
var invoice = CreateValidInvoice();
var existingTransaction = new Transaction { GatewayId = invoice.Id };
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
_transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id).Returns(existingTransaction);
var result = await controller.PostIpn(eventModel, _validWebhookKey);
var okResult = Assert.IsType<OkObjectResult>(result);
Assert.Equal("Invoice already processed", okResult.Value);
}
[Fact]
public async Task PostIpn_ValidOrganizationTransaction_Success()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
var organizationId = Guid.NewGuid();
var invoice = CreateValidInvoice(posData: $"organizationId:{organizationId},{PosDataKeys.AccountCredit}");
var organization = new Organization { Id = organizationId, BillingEmail = "billing@example.com" };
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
_transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id).Returns((Transaction)null);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
_paymentService.CreditAccountAsync(organization, Arg.Any<decimal>()).Returns(true);
var result = await controller.PostIpn(eventModel, _validWebhookKey);
Assert.IsType<OkResult>(result);
await _transactionRepository.Received(1).CreateAsync(Arg.Is<Transaction>(t =>
t.OrganizationId == organizationId &&
t.Type == TransactionType.Credit &&
t.Gateway == GatewayType.BitPay &&
t.PaymentMethodType == PaymentMethodType.BitPay));
await _organizationRepository.Received(1).ReplaceAsync(organization);
await _mailService.Received(1).SendAddedCreditAsync("billing@example.com", 100.00m);
}
[Fact]
public async Task PostIpn_ValidUserTransaction_Success()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
var userId = Guid.NewGuid();
var invoice = CreateValidInvoice(posData: $"userId:{userId},{PosDataKeys.AccountCredit}");
var user = new User { Id = userId, Email = "user@example.com" };
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
_transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id).Returns((Transaction)null);
_userRepository.GetByIdAsync(userId).Returns(user);
var result = await controller.PostIpn(eventModel, _validWebhookKey);
Assert.IsType<OkResult>(result);
await _transactionRepository.Received(1).CreateAsync(Arg.Is<Transaction>(t =>
t.UserId == userId &&
t.Type == TransactionType.Credit &&
t.Gateway == GatewayType.BitPay &&
t.PaymentMethodType == PaymentMethodType.BitPay));
await _premiumUserBillingService.Received(1).Credit(user, 100.00m);
await _mailService.Received(1).SendAddedCreditAsync("user@example.com", 100.00m);
}
[Fact]
public async Task PostIpn_ValidProviderTransaction_Success()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
var providerId = Guid.NewGuid();
var invoice = CreateValidInvoice(posData: $"providerId:{providerId},{PosDataKeys.AccountCredit}");
var provider = new Provider { Id = providerId, BillingEmail = "provider@example.com" };
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
_transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id).Returns((Transaction)null);
_providerRepository.GetByIdAsync(providerId).Returns(Task.FromResult(provider));
_paymentService.CreditAccountAsync(provider, Arg.Any<decimal>()).Returns(true);
var result = await controller.PostIpn(eventModel, _validWebhookKey);
Assert.IsType<OkResult>(result);
await _transactionRepository.Received(1).CreateAsync(Arg.Is<Transaction>(t =>
t.ProviderId == providerId &&
t.Type == TransactionType.Credit &&
t.Gateway == GatewayType.BitPay &&
t.PaymentMethodType == PaymentMethodType.BitPay));
await _providerRepository.Received(1).ReplaceAsync(provider);
await _mailService.Received(1).SendAddedCreditAsync("provider@example.com", 100.00m);
}
[Fact]
public void GetIdsFromPosData_ValidOrganizationId_ReturnsCorrectId()
{
var controller = CreateController();
var organizationId = Guid.NewGuid();
var invoice = CreateValidInvoice(posData: $"organizationId:{organizationId},{PosDataKeys.AccountCredit}");
var result = controller.GetIdsFromPosData(invoice);
Assert.Equal(organizationId, result.OrganizationId);
Assert.Null(result.UserId);
Assert.Null(result.ProviderId);
}
[Fact]
public void GetIdsFromPosData_ValidUserId_ReturnsCorrectId()
{
var controller = CreateController();
var userId = Guid.NewGuid();
var invoice = CreateValidInvoice(posData: $"userId:{userId},{PosDataKeys.AccountCredit}");
var result = controller.GetIdsFromPosData(invoice);
Assert.Null(result.OrganizationId);
Assert.Equal(userId, result.UserId);
Assert.Null(result.ProviderId);
}
[Fact]
public void GetIdsFromPosData_ValidProviderId_ReturnsCorrectId()
{
var controller = CreateController();
var providerId = Guid.NewGuid();
var invoice = CreateValidInvoice(posData: $"providerId:{providerId},{PosDataKeys.AccountCredit}");
var result = controller.GetIdsFromPosData(invoice);
Assert.Null(result.OrganizationId);
Assert.Null(result.UserId);
Assert.Equal(providerId, result.ProviderId);
}
[Fact]
public void GetIdsFromPosData_InvalidGuid_ReturnsNull()
{
var controller = CreateController();
var invoice = CreateValidInvoice(posData: "organizationId:invalid-guid,{PosDataKeys.AccountCredit}");
var result = controller.GetIdsFromPosData(invoice);
Assert.Null(result.OrganizationId);
Assert.Null(result.UserId);
Assert.Null(result.ProviderId);
}
[Fact]
public void GetIdsFromPosData_NullPosData_ReturnsNull()
{
var controller = CreateController();
var invoice = CreateValidInvoice(posData: null!);
var result = controller.GetIdsFromPosData(invoice);
Assert.Null(result.OrganizationId);
Assert.Null(result.UserId);
Assert.Null(result.ProviderId);
}
[Fact]
public void GetIdsFromPosData_EmptyPosData_ReturnsNull()
{
var controller = CreateController();
var invoice = CreateValidInvoice(posData: "");
var result = controller.GetIdsFromPosData(invoice);
Assert.Null(result.OrganizationId);
Assert.Null(result.UserId);
Assert.Null(result.ProviderId);
}
private static BitPayEventModel CreateValidEventModel(string invoiceId = "test-invoice-id")
{
return new BitPayEventModel
{
Event = new BitPayEventModel.EventModel { Code = 1005, Name = "invoice_confirmed" },
Data = new BitPayEventModel.InvoiceDataModel { Id = invoiceId }
};
}
private static Invoice CreateValidInvoice(string invoiceId = "test-invoice-id", string status = "complete",
string currency = "USD", decimal price = 100.00m,
string posData = "organizationId:550e8400-e29b-41d4-a716-446655440000,accountCredit:1")
{
return new Invoice
{
Id = invoiceId,
Status = status,
Currency = currency,
Price = (double)price,
PosData = posData,
CurrentTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
Transactions =
[
new InvoiceTransaction
{
Type = null,
Confirmations = "1",
ReceivedTime = DateTime.UtcNow.ToString("O")
}
]
};
}
}