1
0
mirror of https://github.com/bitwarden/server synced 2025-12-27 05:33:17 +00:00

Merge branch 'main' into SM-1571-DisableSMAdsForUsers

This commit is contained in:
cd-bitwarden
2025-10-28 14:10:57 -04:00
committed by GitHub
96 changed files with 17627 additions and 1014 deletions

View File

@@ -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" });

View File

@@ -0,0 +1,112 @@
using System.Text.Json;
using Bit.Core.Billing.Payment.Models;
using Xunit;
namespace Bit.Core.Test.Billing.Payment.Models;
public class PaymentMethodTests
{
[Theory]
[InlineData("{\"cardNumber\":\"1234\"}")]
[InlineData("{\"type\":\"unknown_type\",\"data\":\"value\"}")]
[InlineData("{\"type\":\"invalid\",\"token\":\"test-token\"}")]
[InlineData("{\"type\":\"invalid\"}")]
public void Read_ShouldThrowJsonException_OnInvalidOrMissingType(string json)
{
// Arrange
var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } };
// Act & Assert
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<PaymentMethod>(json, options));
}
[Theory]
[InlineData("{\"type\":\"card\"}")]
[InlineData("{\"type\":\"card\",\"token\":\"\"}")]
[InlineData("{\"type\":\"card\",\"token\":null}")]
public void Read_ShouldThrowJsonException_OnInvalidTokenizedPaymentMethodToken(string json)
{
// Arrange
var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } };
// Act & Assert
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<PaymentMethod>(json, options));
}
// Tokenized payment method deserialization
[Theory]
[InlineData("bankAccount", TokenizablePaymentMethodType.BankAccount)]
[InlineData("card", TokenizablePaymentMethodType.Card)]
[InlineData("payPal", TokenizablePaymentMethodType.PayPal)]
public void Read_ShouldDeserializeTokenizedPaymentMethods(string typeString, TokenizablePaymentMethodType expectedType)
{
// Arrange
var json = $"{{\"type\":\"{typeString}\",\"token\":\"test-token\"}}";
var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } };
// Act
var result = JsonSerializer.Deserialize<PaymentMethod>(json, options);
// Assert
Assert.True(result.IsTokenized);
Assert.Equal(expectedType, result.AsT0.Type);
Assert.Equal("test-token", result.AsT0.Token);
}
// Non-tokenized payment method deserialization
[Theory]
[InlineData("accountcredit", NonTokenizablePaymentMethodType.AccountCredit)]
public void Read_ShouldDeserializeNonTokenizedPaymentMethods(string typeString, NonTokenizablePaymentMethodType expectedType)
{
// Arrange
var json = $"{{\"type\":\"{typeString}\"}}";
var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } };
// Act
var result = JsonSerializer.Deserialize<PaymentMethod>(json, options);
// Assert
Assert.True(result.IsNonTokenized);
Assert.Equal(expectedType, result.AsT1.Type);
}
// Tokenized payment method serialization
[Theory]
[InlineData(TokenizablePaymentMethodType.BankAccount, "bankaccount")]
[InlineData(TokenizablePaymentMethodType.Card, "card")]
[InlineData(TokenizablePaymentMethodType.PayPal, "paypal")]
public void Write_ShouldSerializeTokenizedPaymentMethods(TokenizablePaymentMethodType type, string expectedTypeString)
{
// Arrange
var paymentMethod = new PaymentMethod(new TokenizedPaymentMethod
{
Type = type,
Token = "test-token"
});
var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } };
// Act
var json = JsonSerializer.Serialize(paymentMethod, options);
// Assert
Assert.Contains($"\"type\":\"{expectedTypeString}\"", json);
Assert.Contains("\"token\":\"test-token\"", json);
}
// Non-tokenized payment method serialization
[Theory]
[InlineData(NonTokenizablePaymentMethodType.AccountCredit, "accountcredit")]
public void Write_ShouldSerializeNonTokenizedPaymentMethods(NonTokenizablePaymentMethodType type, string expectedTypeString)
{
// Arrange
var paymentMethod = new PaymentMethod(new NonTokenizedPaymentMethod { Type = type });
var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } };
// Act
var json = JsonSerializer.Serialize(paymentMethod, options);
// Assert
Assert.Contains($"\"type\":\"{expectedTypeString}\"", json);
Assert.DoesNotContain("token", json);
}
}

View File

@@ -1,4 +1,5 @@
using Bit.Core.Billing.Caches;
using Bit.Core.Billing;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Payment.Models;
@@ -567,4 +568,79 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
var unhandled = result.AsT3;
Assert.Equal("Something went wrong with your request. Please contact support for assistance.", unhandled.Response);
}
[Theory, BitAutoData]
public async Task Run_AccountCredit_WithExistingCustomer_Success(
User user,
NonTokenizedPaymentMethod paymentMethod,
BillingAddress billingAddress)
{
// Arrange
user.Premium = false;
user.GatewayCustomerId = "existing_customer_123";
paymentMethod.Type = NonTokenizablePaymentMethodType.AccountCredit;
billingAddress.Country = "US";
billingAddress.PostalCode = "12345";
var mockCustomer = Substitute.For<StripeCustomer>();
mockCustomer.Id = "existing_customer_123";
mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" };
mockCustomer.Metadata = new Dictionary<string, string>();
var mockSubscription = Substitute.For<StripeSubscription>();
mockSubscription.Id = "sub_123";
mockSubscription.Status = "active";
mockSubscription.Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
}
]
};
var mockInvoice = Substitute.For<Invoice>();
_subscriberService.GetCustomerOrThrow(Arg.Any<User>(), Arg.Any<CustomerGetOptions>()).Returns(mockCustomer);
_stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(mockSubscription);
_stripeAdapter.InvoiceUpdateAsync(Arg.Any<string>(), Arg.Any<InvoiceUpdateOptions>()).Returns(mockInvoice);
// Act
var result = await _command.Run(user, paymentMethod, billingAddress, 0);
// Assert
Assert.True(result.IsT0);
await _subscriberService.Received(1).GetCustomerOrThrow(Arg.Any<User>(), Arg.Any<CustomerGetOptions>());
await _stripeAdapter.DidNotReceive().CustomerCreateAsync(Arg.Any<CustomerCreateOptions>());
Assert.True(user.Premium);
Assert.Equal(mockSubscription.GetCurrentPeriodEnd(), user.PremiumExpirationDate);
}
[Theory, BitAutoData]
public async Task Run_NonTokenizedPaymentWithoutExistingCustomer_ThrowsBillingException(
User user,
NonTokenizedPaymentMethod paymentMethod,
BillingAddress billingAddress)
{
// Arrange
user.Premium = false;
// No existing gateway customer ID
user.GatewayCustomerId = null;
paymentMethod.Type = NonTokenizablePaymentMethodType.AccountCredit;
billingAddress.Country = "US";
billingAddress.PostalCode = "12345";
// Act
var result = await _command.Run(user, paymentMethod, billingAddress, 0);
//Assert
Assert.True(result.IsT3); // Assuming T3 is the Unhandled result
Assert.IsType<BillingException>(result.AsT3.Exception);
// Verify no customer was created or subscription attempted
await _stripeAdapter.DidNotReceive().CustomerCreateAsync(Arg.Any<CustomerCreateOptions>());
await _stripeAdapter.DidNotReceive().SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>());
await _userService.DidNotReceive().SaveUserAsync(Arg.Any<User>());
}
}

View File

@@ -28,6 +28,9 @@
<None Remove="Utilities\data\embeddedResource.txt" />
</ItemGroup>
<ItemGroup>
<!-- Email templates uses .hbs extension, they must be included for emails to work -->
<EmbeddedResource Include="**\*.hbs" />
<EmbeddedResource Include="Utilities\data\embeddedResource.txt" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,20 @@
using Bit.Core.Platform.Mailer;
using Bit.Core.Test.Platform.Mailer.TestMail;
using Xunit;
namespace Bit.Core.Test.Platform.Mailer;
public class HandlebarMailRendererTests
{
[Fact]
public async Task RenderAsync_ReturnsExpectedHtmlAndTxt()
{
var renderer = new HandlebarMailRenderer();
var view = new TestMailView { Name = "John Smith" };
var (html, txt) = await renderer.RenderAsync(view);
Assert.Equal("Hello <b>John Smith</b>", html.Trim());
Assert.Equal("Hello John Smith", txt.Trim());
}
}

View File

@@ -0,0 +1,37 @@
using Bit.Core.Models.Mail;
using Bit.Core.Platform.Mailer;
using Bit.Core.Services;
using Bit.Core.Test.Platform.Mailer.TestMail;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Platform.Mailer;
public class MailerTest
{
[Fact]
public async Task SendEmailAsync()
{
var deliveryService = Substitute.For<IMailDeliveryService>();
var mailer = new Core.Platform.Mailer.Mailer(new HandlebarMailRenderer(), deliveryService);
var mail = new TestMail.TestMail()
{
ToEmails = ["test@bw.com"],
View = new TestMailView() { Name = "John Smith" }
};
MailMessage? sentMessage = null;
await deliveryService.SendEmailAsync(Arg.Do<MailMessage>(message =>
sentMessage = message
));
await mailer.SendEmail(mail);
Assert.NotNull(sentMessage);
Assert.Contains("test@bw.com", sentMessage.ToEmails);
Assert.Equal("Test Email", sentMessage.Subject);
Assert.Equivalent("Hello John Smith", sentMessage.TextContent.Trim());
Assert.Equivalent("Hello <b>John Smith</b>", sentMessage.HtmlContent.Trim());
}
}

View File

@@ -0,0 +1,13 @@
using Bit.Core.Platform.Mailer;
namespace Bit.Core.Test.Platform.Mailer.TestMail;
public class TestMailView : BaseMailView
{
public required string Name { get; init; }
}
public class TestMail : BaseMail<TestMailView>
{
public override string Subject { get; } = "Test Email";
}

View File

@@ -0,0 +1 @@
Hello <b>{{ Name }}</b>

View File

@@ -0,0 +1 @@
Hello {{ Name }}