mirror of
https://github.com/bitwarden/server
synced 2025-12-06 00:03:34 +00:00
[PM-25183] Update the BitPay purchasing procedure (#6396)
* Revise BitPay controller * Run dotnet format * Kyle's feedback * Run dotnet format * Temporary logging * Whoops * Undo temporary logging
This commit is contained in:
391
test/Billing.Test/Controllers/BitPayControllerTests.cs
Normal file
391
test/Billing.Test/Controllers/BitPayControllerTests.cs
Normal file
@@ -0,0 +1,391 @@
|
||||
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 IPaymentService _paymentService = Substitute.For<IPaymentService>();
|
||||
|
||||
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")
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Payment.Clients;
|
||||
using Bit.Core.Billing.Payment.Commands;
|
||||
using Bit.Core.Entities;
|
||||
@@ -11,12 +12,18 @@ using Invoice = BitPayLight.Models.Invoice.Invoice;
|
||||
|
||||
namespace Bit.Core.Test.Billing.Payment.Commands;
|
||||
|
||||
using static BitPayConstants;
|
||||
|
||||
public class CreateBitPayInvoiceForCreditCommandTests
|
||||
{
|
||||
private readonly IBitPayClient _bitPayClient = Substitute.For<IBitPayClient>();
|
||||
private readonly GlobalSettings _globalSettings = new()
|
||||
{
|
||||
BitPay = new GlobalSettings.BitPaySettings { NotificationUrl = "https://example.com/bitpay/notification" }
|
||||
BitPay = new GlobalSettings.BitPaySettings
|
||||
{
|
||||
NotificationUrl = "https://example.com/bitpay/notification",
|
||||
WebhookKey = "test-webhook-key"
|
||||
}
|
||||
};
|
||||
private const string _redirectUrl = "https://bitwarden.com/redirect";
|
||||
private readonly CreateBitPayInvoiceForCreditCommand _command;
|
||||
@@ -37,8 +44,8 @@ public class CreateBitPayInvoiceForCreditCommandTests
|
||||
_bitPayClient.CreateInvoice(Arg.Is<Invoice>(options =>
|
||||
options.Buyer.Email == user.Email &&
|
||||
options.Buyer.Name == user.Email &&
|
||||
options.NotificationUrl == _globalSettings.BitPay.NotificationUrl &&
|
||||
options.PosData == $"userId:{user.Id},accountCredit:1" &&
|
||||
options.NotificationUrl == $"{_globalSettings.BitPay.NotificationUrl}?key={_globalSettings.BitPay.WebhookKey}" &&
|
||||
options.PosData == $"userId:{user.Id},{PosDataKeys.AccountCredit}" &&
|
||||
// ReSharper disable once CompareOfFloatsByEqualityOperator
|
||||
options.Price == Convert.ToDouble(10M) &&
|
||||
options.RedirectUrl == _redirectUrl)).Returns(new Invoice { Url = "https://bitpay.com/invoice/123" });
|
||||
@@ -58,8 +65,8 @@ public class CreateBitPayInvoiceForCreditCommandTests
|
||||
_bitPayClient.CreateInvoice(Arg.Is<Invoice>(options =>
|
||||
options.Buyer.Email == organization.BillingEmail &&
|
||||
options.Buyer.Name == organization.Name &&
|
||||
options.NotificationUrl == _globalSettings.BitPay.NotificationUrl &&
|
||||
options.PosData == $"organizationId:{organization.Id},accountCredit:1" &&
|
||||
options.NotificationUrl == $"{_globalSettings.BitPay.NotificationUrl}?key={_globalSettings.BitPay.WebhookKey}" &&
|
||||
options.PosData == $"organizationId:{organization.Id},{PosDataKeys.AccountCredit}" &&
|
||||
// ReSharper disable once CompareOfFloatsByEqualityOperator
|
||||
options.Price == Convert.ToDouble(10M) &&
|
||||
options.RedirectUrl == _redirectUrl)).Returns(new Invoice { Url = "https://bitpay.com/invoice/123" });
|
||||
@@ -79,8 +86,8 @@ public class CreateBitPayInvoiceForCreditCommandTests
|
||||
_bitPayClient.CreateInvoice(Arg.Is<Invoice>(options =>
|
||||
options.Buyer.Email == provider.BillingEmail &&
|
||||
options.Buyer.Name == provider.Name &&
|
||||
options.NotificationUrl == _globalSettings.BitPay.NotificationUrl &&
|
||||
options.PosData == $"providerId:{provider.Id},accountCredit:1" &&
|
||||
options.NotificationUrl == $"{_globalSettings.BitPay.NotificationUrl}?key={_globalSettings.BitPay.WebhookKey}" &&
|
||||
options.PosData == $"providerId:{provider.Id},{PosDataKeys.AccountCredit}" &&
|
||||
// ReSharper disable once CompareOfFloatsByEqualityOperator
|
||||
options.Price == Convert.ToDouble(10M) &&
|
||||
options.RedirectUrl == _redirectUrl)).Returns(new Invoice { Url = "https://bitpay.com/invoice/123" });
|
||||
|
||||
Reference in New Issue
Block a user