using System.Reflection; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Business; using Bit.Core.Entities; using Bit.Core.Models.Mail; using Bit.Core.Platform.Mail.Delivery; using Bit.Core.Platform.Mail.Enqueuing; using Bit.Core.Services; using Bit.Core.Services.Mail; using Bit.Core.Settings; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; namespace Bit.Core.Test.Services; public class HandlebarsMailServiceTests { private readonly HandlebarsMailService _sut; private readonly GlobalSettings _globalSettings; private readonly IMailDeliveryService _mailDeliveryService; private readonly IMailEnqueuingService _mailEnqueuingService; private readonly IDistributedCache _distributedCache; private readonly ILogger _logger; public HandlebarsMailServiceTests() { _globalSettings = new GlobalSettings(); _mailDeliveryService = Substitute.For(); _mailEnqueuingService = Substitute.For(); _distributedCache = Substitute.For(); _logger = Substitute.For>(); _sut = new HandlebarsMailService( _globalSettings, _mailDeliveryService, _mailEnqueuingService, _distributedCache, _logger ); } [Fact] public async Task SendFailedTwoFactorAttemptEmailAsync_FirstCall_SendsEmail() { // Arrange var email = "test@example.com"; var failedType = TwoFactorProviderType.Email; var utcNow = DateTime.UtcNow; var ip = "192.168.1.1"; _distributedCache.GetAsync(Arg.Any()).Returns((byte[])null); // Act await _sut.SendFailedTwoFactorAttemptEmailAsync(email, failedType, utcNow, ip); // Assert await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Any()); await _distributedCache.Received(1).SetAsync( Arg.Is(key => key == $"FailedTwoFactorAttemptEmail_{email}"), Arg.Any(), Arg.Any() ); } [Fact] public async Task SendFailedTwoFactorAttemptEmailAsync_SecondCallWithinHour_DoesNotSendEmail() { // Arrange var email = "test@example.com"; var failedType = TwoFactorProviderType.Email; var utcNow = DateTime.UtcNow; var ip = "192.168.1.1"; // Simulate cache hit (email was already sent) _distributedCache.GetAsync(Arg.Any()).Returns([1]); // Act await _sut.SendFailedTwoFactorAttemptEmailAsync(email, failedType, utcNow, ip); // Assert await _mailDeliveryService.DidNotReceive().SendEmailAsync(Arg.Any()); await _distributedCache.DidNotReceive().SetAsync(Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task SendFailedTwoFactorAttemptEmailAsync_DifferentEmails_SendsBothEmails() { // Arrange var email1 = "test1@example.com"; var email2 = "test2@example.com"; var failedType = TwoFactorProviderType.Email; var utcNow = DateTime.UtcNow; var ip = "192.168.1.1"; _distributedCache.GetAsync(Arg.Any()).Returns((byte[])null); // Act await _sut.SendFailedTwoFactorAttemptEmailAsync(email1, failedType, utcNow, ip); await _sut.SendFailedTwoFactorAttemptEmailAsync(email2, failedType, utcNow, ip); // Assert await _mailDeliveryService.Received(2).SendEmailAsync(Arg.Any()); await _distributedCache.Received(1).SetAsync( Arg.Is(key => key == $"FailedTwoFactorAttemptEmail_{email1}"), Arg.Any(), Arg.Any() ); await _distributedCache.Received(1).SetAsync( Arg.Is(key => key == $"FailedTwoFactorAttemptEmail_{email2}"), Arg.Any(), Arg.Any() ); } [Fact(Skip = "For local development")] public async Task SendAllEmails() { // This test is only opt in and is more for development purposes. // This will send all emails to the test email address so that they can be viewed. var namedParameters = new Dictionary<(string, Type), object> { // TODO: Switch to use env variable { ("email", typeof(string)), "test@bitwarden.com" }, { ("user", typeof(User)), new User { Id = Guid.NewGuid(), Email = "test@bitwarden.com", }}, { ("userId", typeof(Guid)), Guid.NewGuid() }, { ("token", typeof(string)), "test_token" }, { ("fromEmail", typeof(string)), "test@bitwarden.com" }, { ("toEmail", typeof(string)), "test@bitwarden.com" }, { ("newEmailAddress", typeof(string)), "test@bitwarden.com" }, { ("hint", typeof(string)), "Test Hint" }, { ("organizationName", typeof(string)), "Test Organization Name" }, { ("orgUser", typeof(OrganizationUser)), new OrganizationUser { Id = Guid.NewGuid(), Email = "test@bitwarden.com", OrganizationId = Guid.NewGuid(), }}, { ("token", typeof(ExpiringToken)), new ExpiringToken("test_token", DateTime.UtcNow.AddDays(1))}, { ("organization", typeof(Organization)), new Organization { Id = Guid.NewGuid(), Name = "Test Organization Name", Seats = 5 }}, { ("initialSeatCount", typeof(int)), 5}, { ("ownerEmails", typeof(IEnumerable)), new [] { "test@bitwarden.com" }}, { ("maxSeatCount", typeof(int)), 5 }, { ("userIdentifier", typeof(string)), "test_user" }, { ("adminEmails", typeof(IEnumerable)), new [] { "test@bitwarden.com" }}, { ("returnUrl", typeof(string)), "https://bitwarden.com/" }, { ("amount", typeof(decimal)), 1.00M }, { ("dueDate", typeof(DateTime)), DateTime.UtcNow.AddDays(1) }, { ("items", typeof(List)), new List { "test@bitwarden.com" }}, { ("mentionInvoices", typeof(bool)), true }, { ("emails", typeof(IEnumerable)), new [] { "test@bitwarden.com" }}, { ("deviceType", typeof(string)), "Mobile" }, { ("timestamp", typeof(DateTime)), DateTime.UtcNow.AddDays(1)}, { ("ip", typeof(string)), "127.0.0.1" }, { ("emergencyAccess", typeof(EmergencyAccess)), new EmergencyAccess { Id = Guid.NewGuid(), Email = "test@bitwarden.com", }}, { ("granteeEmail", typeof(string)), "test@bitwarden.com" }, { ("grantorName", typeof(string)), "Test User" }, { ("initiatingName", typeof(string)), "Test" }, { ("approvingName", typeof(string)), "Test Name" }, { ("rejectingName", typeof(string)), "Test Name" }, { ("provider", typeof(Provider)), new Provider { Id = Guid.NewGuid(), }}, { ("name", typeof(string)), "Test Name" }, { ("ea", typeof(EmergencyAccess)), new EmergencyAccess { Id = Guid.NewGuid(), Email = "test@bitwarden.com", }}, { ("userName", typeof(string)), "testUser" }, { ("orgName", typeof(string)), "Test Org Name" }, { ("providerName", typeof(string)), "testProvider" }, { ("providerUser", typeof(ProviderUser)), new ProviderUser { ProviderId = Guid.NewGuid(), Id = Guid.NewGuid(), }}, { ("familyUserEmail", typeof(string)), "test@bitwarden.com" }, { ("sponsorEmail", typeof(string)), "test@bitwarden.com" }, { ("familyOrgName", typeof(string)), "Test Org Name" }, // Swap existingAccount to true or false to generate different versions of the SendFamiliesForEnterpriseOfferEmailAsync emails. { ("existingAccount", typeof(bool)), false }, { ("sponsorshipEndDate", typeof(DateTime)), DateTime.UtcNow.AddDays(1)}, { ("sponsorOrgName", typeof(string)), "Sponsor Test Org Name" }, { ("expirationDate", typeof(DateTime)), DateTime.Now.AddDays(3) }, { ("utcNow", typeof(DateTime)), DateTime.UtcNow }, }; var globalSettings = new GlobalSettings { Mail = new GlobalSettings.MailSettings { Smtp = new GlobalSettings.MailSettings.SmtpSettings { Host = "localhost", TrustServer = true, Port = 10250, }, ReplyToEmail = "noreply@bitwarden.com", }, SiteName = "Bitwarden", }; var mailDeliveryService = new MailKitSmtpMailDeliveryService(globalSettings, Substitute.For>()); var distributedCache = Substitute.For(); var logger = Substitute.For>(); var handlebarsService = new HandlebarsMailService(globalSettings, mailDeliveryService, new BlockingMailEnqueuingService(), distributedCache, logger); var sendMethods = typeof(IMailService).GetMethods(BindingFlags.Public | BindingFlags.Instance) .Where(m => m.Name.StartsWith("Send") && m.Name != "SendEnqueuedMailMessageAsync"); foreach (var sendMethod in sendMethods) { await InvokeMethod(sendMethod); } async Task InvokeMethod(MethodInfo method) { var parameters = method.GetParameters(); var args = new object[parameters.Length]; for (var i = 0; i < parameters.Length; i++) { if (!namedParameters.TryGetValue((parameters[i].Name, parameters[i].ParameterType), out var value)) { throw new InvalidOperationException($"Couldn't find a parameter for name '{parameters[i].Name}' and type '{parameters[i].ParameterType.FullName}'"); } args[i] = value; } await (Task)method.Invoke(handlebarsService, args); } } [Fact] public async Task SendSendEmailOtpEmailAsync_SendsEmail() { // Arrange var email = "test@example.com"; var token = "aToken"; var subject = string.Format("Your Bitwarden Send verification code is {0}", token); // Act await _sut.SendSendEmailOtpEmailAsync(email, token, subject); // Assert await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Any()); } }