diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index 590895665d..100cd7caf6 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -12,7 +12,6 @@ using Bit.Api.Models.Request.Accounts; using Bit.Api.Models.Request.Organizations; using Bit.Api.Models.Response; using Bit.Core; -using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; @@ -70,6 +69,7 @@ public class OrganizationsController : Controller private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IPricingClient _pricingClient; private readonly IOrganizationUpdateKeysCommand _organizationUpdateKeysCommand; + private readonly IOrganizationUpdateCommand _organizationUpdateCommand; public OrganizationsController( IOrganizationRepository organizationRepository, @@ -94,7 +94,8 @@ public class OrganizationsController : Controller IOrganizationDeleteCommand organizationDeleteCommand, IPolicyRequirementQuery policyRequirementQuery, IPricingClient pricingClient, - IOrganizationUpdateKeysCommand organizationUpdateKeysCommand) + IOrganizationUpdateKeysCommand organizationUpdateKeysCommand, + IOrganizationUpdateCommand organizationUpdateCommand) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -119,6 +120,7 @@ public class OrganizationsController : Controller _policyRequirementQuery = policyRequirementQuery; _pricingClient = pricingClient; _organizationUpdateKeysCommand = organizationUpdateKeysCommand; + _organizationUpdateCommand = organizationUpdateCommand; } [HttpGet("{id}")] @@ -224,36 +226,31 @@ public class OrganizationsController : Controller return new OrganizationResponseModel(result.Organization, plan); } - [HttpPut("{id}")] - public async Task Put(string id, [FromBody] OrganizationUpdateRequestModel model) + [HttpPut("{organizationId:guid}")] + public async Task Put(Guid organizationId, [FromBody] OrganizationUpdateRequestModel model) { - var orgIdGuid = new Guid(id); + // If billing email is being changed, require subscription editing permissions. + // Otherwise, organization owner permissions are sufficient. + var requiresBillingPermission = model.BillingEmail is not null; + var authorized = requiresBillingPermission + ? await _currentContext.EditSubscription(organizationId) + : await _currentContext.OrganizationOwner(organizationId); - var organization = await _organizationRepository.GetByIdAsync(orgIdGuid); - if (organization == null) + if (!authorized) { - throw new NotFoundException(); + return TypedResults.Unauthorized(); } - var updateBilling = ShouldUpdateBilling(model, organization); + var commandRequest = model.ToCommandRequest(organizationId); + var updatedOrganization = await _organizationUpdateCommand.UpdateAsync(commandRequest); - var hasRequiredPermissions = updateBilling - ? await _currentContext.EditSubscription(orgIdGuid) - : await _currentContext.OrganizationOwner(orgIdGuid); - - if (!hasRequiredPermissions) - { - throw new NotFoundException(); - } - - await _organizationService.UpdateAsync(model.ToOrganization(organization, _globalSettings), updateBilling); - var plan = await _pricingClient.GetPlan(organization.PlanType); - return new OrganizationResponseModel(organization, plan); + var plan = await _pricingClient.GetPlan(updatedOrganization.PlanType); + return TypedResults.Ok(new OrganizationResponseModel(updatedOrganization, plan)); } [HttpPost("{id}")] [Obsolete("This endpoint is deprecated. Use PUT method instead")] - public async Task PostPut(string id, [FromBody] OrganizationUpdateRequestModel model) + public async Task PostPut(Guid id, [FromBody] OrganizationUpdateRequestModel model) { return await Put(id, model); } @@ -588,11 +585,4 @@ public class OrganizationsController : Controller return organization.PlanType; } - - private bool ShouldUpdateBilling(OrganizationUpdateRequestModel model, Organization organization) - { - var organizationNameChanged = model.Name != organization.Name; - var billingEmailChanged = model.BillingEmail != organization.BillingEmail; - return !_globalSettings.SelfHosted && (organizationNameChanged || billingEmailChanged); - } } diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUpdateRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUpdateRequestModel.cs index 5a3192c121..6c3867fe09 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUpdateRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUpdateRequestModel.cs @@ -1,41 +1,28 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; -using Bit.Core.AdminConsole.Entities; -using Bit.Core.Models.Data; -using Bit.Core.Settings; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update; using Bit.Core.Utilities; namespace Bit.Api.AdminConsole.Models.Request.Organizations; public class OrganizationUpdateRequestModel { - [Required] [StringLength(50, ErrorMessage = "The field Name exceeds the maximum length.")] [JsonConverter(typeof(HtmlEncodingStringConverter))] - public string Name { get; set; } - [StringLength(50, ErrorMessage = "The field Business Name exceeds the maximum length.")] - [JsonConverter(typeof(HtmlEncodingStringConverter))] - public string BusinessName { get; set; } - [EmailAddress] - [Required] - [StringLength(256)] - public string BillingEmail { get; set; } - public Permissions Permissions { get; set; } - public OrganizationKeysRequestModel Keys { get; set; } + public string? Name { get; set; } - public virtual Organization ToOrganization(Organization existingOrganization, GlobalSettings globalSettings) + [EmailAddress] + [StringLength(256)] + public string? BillingEmail { get; set; } + + public OrganizationKeysRequestModel? Keys { get; set; } + + public OrganizationUpdateRequest ToCommandRequest(Guid organizationId) => new() { - if (!globalSettings.SelfHosted) - { - // These items come from the license file - existingOrganization.Name = Name; - existingOrganization.BusinessName = BusinessName; - existingOrganization.BillingEmail = BillingEmail?.ToLowerInvariant()?.Trim(); - } - Keys?.ToOrganization(existingOrganization); - return existingOrganization; - } + OrganizationId = organizationId, + Name = Name, + BillingEmail = BillingEmail, + PublicKey = Keys?.PublicKey, + EncryptedPrivateKey = Keys?.EncryptedPrivateKey + }; } diff --git a/src/Billing/Controllers/JobsController.cs b/src/Billing/Controllers/JobsController.cs new file mode 100644 index 0000000000..6a5e8e5531 --- /dev/null +++ b/src/Billing/Controllers/JobsController.cs @@ -0,0 +1,36 @@ +using Bit.Billing.Jobs; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Billing.Controllers; + +[Route("jobs")] +[SelfHosted(NotSelfHostedOnly = true)] +[RequireLowerEnvironment] +public class JobsController( + JobsHostedService jobsHostedService) : Controller +{ + [HttpPost("run/{jobName}")] + public async Task RunJobAsync(string jobName) + { + if (jobName == nameof(ReconcileAdditionalStorageJob)) + { + await jobsHostedService.RunJobAdHocAsync(); + return Ok(new { message = $"Job {jobName} scheduled successfully" }); + } + + return BadRequest(new { error = $"Unknown job name: {jobName}" }); + } + + [HttpPost("stop/{jobName}")] + public async Task StopJobAsync(string jobName) + { + if (jobName == nameof(ReconcileAdditionalStorageJob)) + { + await jobsHostedService.InterruptAdHocJobAsync(); + return Ok(new { message = $"Job {jobName} queued for cancellation" }); + } + + return BadRequest(new { error = $"Unknown job name: {jobName}" }); + } +} diff --git a/src/Billing/Jobs/AliveJob.cs b/src/Billing/Jobs/AliveJob.cs index 42f64099ac..1769cc94e2 100644 --- a/src/Billing/Jobs/AliveJob.cs +++ b/src/Billing/Jobs/AliveJob.cs @@ -10,4 +10,13 @@ public class AliveJob(ILogger logger) : BaseJob(logger) _logger.LogInformation(Core.Constants.BypassFiltersEventId, null, "Billing service is alive!"); return Task.FromResult(0); } + + public static ITrigger GetTrigger() + { + return TriggerBuilder.Create() + .WithIdentity("EveryTopOfTheHourTrigger") + .StartNow() + .WithCronSchedule("0 0 * * * ?") + .Build(); + } } diff --git a/src/Billing/Jobs/JobsHostedService.cs b/src/Billing/Jobs/JobsHostedService.cs index a6e702c662..25c57044da 100644 --- a/src/Billing/Jobs/JobsHostedService.cs +++ b/src/Billing/Jobs/JobsHostedService.cs @@ -1,29 +1,27 @@ -using Bit.Core.Jobs; +using Bit.Core.Exceptions; +using Bit.Core.Jobs; using Bit.Core.Settings; using Quartz; namespace Bit.Billing.Jobs; -public class JobsHostedService : BaseJobsHostedService +public class JobsHostedService( + GlobalSettings globalSettings, + IServiceProvider serviceProvider, + ILogger logger, + ILogger listenerLogger, + ISchedulerFactory schedulerFactory) + : BaseJobsHostedService(globalSettings, serviceProvider, logger, listenerLogger) { - public JobsHostedService( - GlobalSettings globalSettings, - IServiceProvider serviceProvider, - ILogger logger, - ILogger listenerLogger) - : base(globalSettings, serviceProvider, logger, listenerLogger) { } + private List AdHocJobKeys { get; } = []; + private IScheduler? _adHocScheduler; public override async Task StartAsync(CancellationToken cancellationToken) { - var everyTopOfTheHourTrigger = TriggerBuilder.Create() - .WithIdentity("EveryTopOfTheHourTrigger") - .StartNow() - .WithCronSchedule("0 0 * * * ?") - .Build(); - Jobs = new List> { - new Tuple(typeof(AliveJob), everyTopOfTheHourTrigger) + new(typeof(AliveJob), AliveJob.GetTrigger()), + new(typeof(ReconcileAdditionalStorageJob), ReconcileAdditionalStorageJob.GetTrigger()) }; await base.StartAsync(cancellationToken); @@ -33,5 +31,54 @@ public class JobsHostedService : BaseJobsHostedService { services.AddTransient(); services.AddTransient(); + services.AddTransient(); + // add this service as a singleton so we can inject it where needed + services.AddSingleton(); + services.AddHostedService(sp => sp.GetRequiredService()); + } + + public async Task InterruptAdHocJobAsync(CancellationToken cancellationToken = default) where T : class, IJob + { + if (_adHocScheduler == null) + { + throw new InvalidOperationException("AdHocScheduler is null, cannot interrupt ad-hoc job."); + } + + var jobKey = AdHocJobKeys.FirstOrDefault(j => j.Name == typeof(T).ToString()); + if (jobKey == null) + { + throw new NotFoundException($"Cannot find job key: {typeof(T)}, not running?"); + } + logger.LogInformation("CANCELLING ad-hoc job with key: {JobKey}", jobKey); + AdHocJobKeys.Remove(jobKey); + await _adHocScheduler.Interrupt(jobKey, cancellationToken); + } + + public async Task RunJobAdHocAsync(CancellationToken cancellationToken = default) where T : class, IJob + { + _adHocScheduler ??= await schedulerFactory.GetScheduler(cancellationToken); + + var jobKey = new JobKey(typeof(T).ToString()); + + var currentlyExecuting = await _adHocScheduler.GetCurrentlyExecutingJobs(cancellationToken); + if (currentlyExecuting.Any(j => j.JobDetail.Key.Equals(jobKey))) + { + throw new InvalidOperationException($"Job {jobKey} is already running"); + } + + AdHocJobKeys.Add(jobKey); + + var job = JobBuilder.Create() + .WithIdentity(jobKey) + .Build(); + + var trigger = TriggerBuilder.Create() + .WithIdentity(typeof(T).ToString()) + .StartNow() + .Build(); + + logger.LogInformation("Scheduling ad-hoc job with key: {JobKey}", jobKey); + + await _adHocScheduler.ScheduleJob(job, trigger, cancellationToken); } } diff --git a/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs b/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs new file mode 100644 index 0000000000..d891fc18ff --- /dev/null +++ b/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs @@ -0,0 +1,207 @@ +using System.Globalization; +using System.Text.Json; +using Bit.Billing.Services; +using Bit.Core; +using Bit.Core.Billing.Constants; +using Bit.Core.Jobs; +using Bit.Core.Services; +using Quartz; +using Stripe; + +namespace Bit.Billing.Jobs; + +public class ReconcileAdditionalStorageJob( + IStripeFacade stripeFacade, + ILogger logger, + IFeatureService featureService) : BaseJob(logger) +{ + private const string _storageGbMonthlyPriceId = "storage-gb-monthly"; + private const string _storageGbAnnuallyPriceId = "storage-gb-annually"; + private const string _personalStorageGbAnnuallyPriceId = "personal-storage-gb-annually"; + private const int _storageGbToRemove = 4; + + protected override async Task ExecuteJobAsync(IJobExecutionContext context) + { + if (!featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob)) + { + logger.LogInformation("Skipping ReconcileAdditionalStorageJob, feature flag off."); + return; + } + + var liveMode = featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode); + + // Execution tracking + var subscriptionsFound = 0; + var subscriptionsUpdated = 0; + var subscriptionsWithErrors = 0; + var failures = new List(); + + logger.LogInformation("Starting ReconcileAdditionalStorageJob (live mode: {LiveMode})", liveMode); + + var priceIds = new[] { _storageGbMonthlyPriceId, _storageGbAnnuallyPriceId, _personalStorageGbAnnuallyPriceId }; + + foreach (var priceId in priceIds) + { + var options = new SubscriptionListOptions + { + Limit = 100, + Status = StripeConstants.SubscriptionStatus.Active, + Price = priceId + }; + + await foreach (var subscription in stripeFacade.ListSubscriptionsAutoPagingAsync(options)) + { + if (context.CancellationToken.IsCancellationRequested) + { + logger.LogWarning( + "Job cancelled!! Exiting. Progress at time of cancellation: Subscriptions found: {SubscriptionsFound}, " + + "Updated: {SubscriptionsUpdated}, Errors: {SubscriptionsWithErrors}{Failures}", + subscriptionsFound, + liveMode + ? subscriptionsUpdated + : $"(In live mode, would have updated) {subscriptionsUpdated}", + subscriptionsWithErrors, + failures.Count > 0 + ? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}" + : string.Empty + ); + return; + } + + if (subscription == null) + { + continue; + } + + logger.LogInformation("Processing subscription: {SubscriptionId}", subscription.Id); + subscriptionsFound++; + + if (subscription.Metadata?.TryGetValue(StripeConstants.MetadataKeys.StorageReconciled2025, out var dateString) == true) + { + if (DateTime.TryParse(dateString, null, DateTimeStyles.RoundtripKind, out var dateProcessed)) + { + logger.LogInformation("Skipping subscription {SubscriptionId} - already processed on {Date}", + subscription.Id, + dateProcessed.ToString("f")); + continue; + } + } + + var updateOptions = BuildSubscriptionUpdateOptions(subscription, priceId); + + if (updateOptions == null) + { + logger.LogInformation("Skipping subscription {SubscriptionId} - no updates needed", subscription.Id); + continue; + } + + subscriptionsUpdated++; + + if (!liveMode) + { + logger.LogInformation( + "Not live mode (dry-run): Would have updated subscription {SubscriptionId} with item changes: {NewLine}{UpdateOptions}", + subscription.Id, + Environment.NewLine, + JsonSerializer.Serialize(updateOptions)); + continue; + } + + try + { + await stripeFacade.UpdateSubscription(subscription.Id, updateOptions); + logger.LogInformation("Successfully updated subscription: {SubscriptionId}", subscription.Id); + } + catch (Exception ex) + { + subscriptionsWithErrors++; + failures.Add($"Subscription {subscription.Id}: {ex.Message}"); + logger.LogError(ex, "Failed to update subscription {SubscriptionId}: {ErrorMessage}", + subscription.Id, ex.Message); + } + } + } + + logger.LogInformation( + "ReconcileAdditionalStorageJob completed. Subscriptions found: {SubscriptionsFound}, " + + "Updated: {SubscriptionsUpdated}, Errors: {SubscriptionsWithErrors}{Failures}", + subscriptionsFound, + liveMode + ? subscriptionsUpdated + : $"(In live mode, would have updated) {subscriptionsUpdated}", + subscriptionsWithErrors, + failures.Count > 0 + ? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}" + : string.Empty + ); + } + + private SubscriptionUpdateOptions? BuildSubscriptionUpdateOptions( + Subscription subscription, + string targetPriceId) + { + if (subscription.Items?.Data == null) + { + return null; + } + + var updateOptions = new SubscriptionUpdateOptions + { + ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations, + Metadata = new Dictionary + { + [StripeConstants.MetadataKeys.StorageReconciled2025] = DateTime.UtcNow.ToString("o") + }, + Items = [] + }; + + var hasUpdates = false; + + foreach (var item in subscription.Items.Data.Where(item => item?.Price?.Id == targetPriceId)) + { + hasUpdates = true; + var currentQuantity = item.Quantity; + + if (currentQuantity > _storageGbToRemove) + { + var newQuantity = currentQuantity - _storageGbToRemove; + logger.LogInformation( + "Subscription {SubscriptionId}: reducing quantity from {CurrentQuantity} to {NewQuantity} for price {PriceId}", + subscription.Id, + currentQuantity, + newQuantity, + item.Price.Id); + + updateOptions.Items.Add(new SubscriptionItemOptions + { + Id = item.Id, + Quantity = newQuantity + }); + } + else + { + logger.LogInformation("Subscription {SubscriptionId}: deleting storage item with quantity {CurrentQuantity} for price {PriceId}", + subscription.Id, + currentQuantity, + item.Price.Id); + + updateOptions.Items.Add(new SubscriptionItemOptions + { + Id = item.Id, + Deleted = true + }); + } + } + + return hasUpdates ? updateOptions : null; + } + + public static ITrigger GetTrigger() + { + return TriggerBuilder.Create() + .WithIdentity("EveryMorningTrigger") + .StartNow() + .WithCronSchedule("0 0 16 * * ?") // 10am CST daily; the pods execute in UTC time + .Build(); + } +} diff --git a/src/Billing/Services/IStripeFacade.cs b/src/Billing/Services/IStripeFacade.cs index 280a3aca3c..90db4a4c82 100644 --- a/src/Billing/Services/IStripeFacade.cs +++ b/src/Billing/Services/IStripeFacade.cs @@ -78,6 +78,11 @@ public interface IStripeFacade RequestOptions requestOptions = null, CancellationToken cancellationToken = default); + IAsyncEnumerable ListSubscriptionsAutoPagingAsync( + SubscriptionListOptions options = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default); + Task GetSubscription( string subscriptionId, SubscriptionGetOptions subscriptionGetOptions = null, diff --git a/src/Billing/Services/Implementations/StripeFacade.cs b/src/Billing/Services/Implementations/StripeFacade.cs index eef7ce009e..7b714b4a8e 100644 --- a/src/Billing/Services/Implementations/StripeFacade.cs +++ b/src/Billing/Services/Implementations/StripeFacade.cs @@ -98,6 +98,12 @@ public class StripeFacade : IStripeFacade CancellationToken cancellationToken = default) => await _subscriptionService.ListAsync(options, requestOptions, cancellationToken); + public IAsyncEnumerable ListSubscriptionsAutoPagingAsync( + SubscriptionListOptions options = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + _subscriptionService.ListAutoPagingAsync(options, requestOptions, cancellationToken); + public async Task GetSubscription( string subscriptionId, SubscriptionGetOptions subscriptionGetOptions = null, diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index 6db0cb6373..0bb51ba9f2 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -1,4 +1,5 @@ -using Bit.Core; +using System.Globalization; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Repositories; @@ -8,7 +9,7 @@ using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Pricing; using Bit.Core.Entities; -using Bit.Core.Models.Mail.UpdatedInvoiceIncoming; +using Bit.Core.Models.Mail.Billing.Renewal.Families2020Renewal; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Platform.Mail.Mailer; using Bit.Core.Repositories; @@ -16,6 +17,7 @@ using Bit.Core.Services; using Stripe; using Event = Stripe.Event; using Plan = Bit.Core.Models.StaticStore.Plan; +using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan; namespace Bit.Billing.Services.Implementations; @@ -107,13 +109,22 @@ public class UpcomingInvoiceHandler( var milestone3 = featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3); - await AlignOrganizationSubscriptionConcernsAsync( + var subscriptionAligned = await AlignOrganizationSubscriptionConcernsAsync( organization, @event, subscription, plan, milestone3); + /* + * Subscription alignment sends out a different version of our Upcoming Invoice email, so we don't need to continue + * with processing. + */ + if (subscriptionAligned) + { + return; + } + // Don't send the upcoming invoice email unless the organization's on an annual plan. if (!plan.IsAnnual) { @@ -135,9 +146,7 @@ public class UpcomingInvoiceHandler( } } - await (milestone3 - ? SendUpdatedUpcomingInvoiceEmailsAsync([organization.BillingEmail]) - : SendUpcomingInvoiceEmailsAsync([organization.BillingEmail], invoice)); + await SendUpcomingInvoiceEmailsAsync([organization.BillingEmail], invoice); } private async Task AlignOrganizationTaxConcernsAsync( @@ -188,7 +197,16 @@ public class UpcomingInvoiceHandler( } } - private async Task AlignOrganizationSubscriptionConcernsAsync( + /// + /// Aligns the organization's subscription details with the specified plan and milestone requirements. + /// + /// The organization whose subscription is being updated. + /// The Stripe event associated with this operation. + /// The organization's subscription. + /// The organization's current plan. + /// A flag indicating whether the third milestone is enabled. + /// Whether the operation resulted in an updated subscription. + private async Task AlignOrganizationSubscriptionConcernsAsync( Organization organization, Event @event, Subscription subscription, @@ -198,7 +216,7 @@ public class UpcomingInvoiceHandler( // currently these are the only plans that need aligned and both require the same flag and share most of the logic if (!milestone3 || plan.Type is not (PlanType.FamiliesAnnually2019 or PlanType.FamiliesAnnually2025)) { - return; + return false; } var passwordManagerItem = @@ -208,15 +226,15 @@ public class UpcomingInvoiceHandler( { logger.LogWarning("Could not find Organization's ({OrganizationId}) password manager item while processing '{EventType}' event ({EventID})", organization.Id, @event.Type, @event.Id); - return; + return false; } - var families = await pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually); + var familiesPlan = await pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually); - organization.PlanType = families.Type; - organization.Plan = families.Name; - organization.UsersGetPremium = families.UsersGetPremium; - organization.Seats = families.PasswordManager.BaseSeats; + organization.PlanType = familiesPlan.Type; + organization.Plan = familiesPlan.Name; + organization.UsersGetPremium = familiesPlan.UsersGetPremium; + organization.Seats = familiesPlan.PasswordManager.BaseSeats; var options = new SubscriptionUpdateOptions { @@ -225,7 +243,7 @@ public class UpcomingInvoiceHandler( new SubscriptionItemOptions { Id = passwordManagerItem.Id, - Price = families.PasswordManager.StripePlanId + Price = familiesPlan.PasswordManager.StripePlanId } ], ProrationBehavior = ProrationBehavior.None @@ -266,6 +284,8 @@ public class UpcomingInvoiceHandler( { await organizationRepository.ReplaceAsync(organization); await stripeFacade.UpdateSubscription(subscription.Id, options); + await SendFamiliesRenewalEmailAsync(organization, familiesPlan); + return true; } catch (Exception exception) { @@ -275,6 +295,7 @@ public class UpcomingInvoiceHandler( organization.Id, @event.Type, @event.Id); + return false; } } @@ -303,14 +324,21 @@ public class UpcomingInvoiceHandler( var milestone2Feature = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2); if (milestone2Feature) { - await AlignPremiumUsersSubscriptionConcernsAsync(user, @event, subscription); + var subscriptionAligned = await AlignPremiumUsersSubscriptionConcernsAsync(user, @event, subscription); + + /* + * Subscription alignment sends out a different version of our Upcoming Invoice email, so we don't need to continue + * with processing. + */ + if (subscriptionAligned) + { + return; + } } if (user.Premium) { - await (milestone2Feature - ? SendUpdatedUpcomingInvoiceEmailsAsync(new List { user.Email }) - : SendUpcomingInvoiceEmailsAsync(new List { user.Email }, invoice)); + await SendUpcomingInvoiceEmailsAsync(new List { user.Email }, invoice); } } @@ -341,7 +369,7 @@ public class UpcomingInvoiceHandler( } } - private async Task AlignPremiumUsersSubscriptionConcernsAsync( + private async Task AlignPremiumUsersSubscriptionConcernsAsync( User user, Event @event, Subscription subscription) @@ -352,7 +380,7 @@ public class UpcomingInvoiceHandler( { logger.LogWarning("Could not find User's ({UserID}) premium subscription item while processing '{EventType}' event ({EventID})", user.Id, @event.Type, @event.Id); - return; + return false; } try @@ -371,6 +399,8 @@ public class UpcomingInvoiceHandler( ], ProrationBehavior = ProrationBehavior.None }); + await SendPremiumRenewalEmailAsync(user, plan); + return true; } catch (Exception exception) { @@ -379,6 +409,7 @@ public class UpcomingInvoiceHandler( "Failed to update user's ({UserID}) subscription price id while processing event with ID {EventID}", user.Id, @event.Id); + return false; } } @@ -513,15 +544,38 @@ public class UpcomingInvoiceHandler( } } - private async Task SendUpdatedUpcomingInvoiceEmailsAsync(IEnumerable emails) + private async Task SendFamiliesRenewalEmailAsync( + Organization organization, + Plan familiesPlan) { - var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); - var updatedUpcomingEmail = new UpdatedInvoiceUpcomingMail + var email = new Families2020RenewalMail { - ToEmails = validEmails, - View = new UpdatedInvoiceUpcomingView() + ToEmails = [organization.BillingEmail], + View = new Families2020RenewalMailView + { + MonthlyRenewalPrice = (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")) + } }; - await mailer.SendEmail(updatedUpcomingEmail); + + await mailer.SendEmail(email); + } + + private async Task SendPremiumRenewalEmailAsync( + User user, + PremiumPlan premiumPlan) + { + /* TODO: Replace with proper premium renewal email template once finalized. + Using Families2020RenewalMail as a temporary stop-gap. */ + var email = new Families2020RenewalMail + { + ToEmails = [user.Email], + View = new Families2020RenewalMailView + { + MonthlyRenewalPrice = (premiumPlan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")) + } + }; + + await mailer.SendEmail(email); } #endregion diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationUpdateCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationUpdateCommand.cs new file mode 100644 index 0000000000..85fbcd2740 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationUpdateCommand.cs @@ -0,0 +1,15 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; + +public interface IOrganizationUpdateCommand +{ + /// + /// Updates an organization's information in the Bitwarden database and Stripe (if required). + /// Also optionally updates an organization's public-private keypair if it was not created with one. + /// On self-host, only the public-private keys will be updated because all other properties are fixed by the license file. + /// + /// The update request containing the details to be updated. + Task UpdateAsync(OrganizationUpdateRequest request); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateCommand.cs new file mode 100644 index 0000000000..64358f3048 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateCommand.cs @@ -0,0 +1,77 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.Billing.Organizations.Services; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update; + +public class OrganizationUpdateCommand( + IOrganizationService organizationService, + IOrganizationRepository organizationRepository, + IGlobalSettings globalSettings, + IOrganizationBillingService organizationBillingService +) : IOrganizationUpdateCommand +{ + public async Task UpdateAsync(OrganizationUpdateRequest request) + { + var organization = await organizationRepository.GetByIdAsync(request.OrganizationId); + if (organization == null) + { + throw new NotFoundException(); + } + + if (globalSettings.SelfHosted) + { + return await UpdateSelfHostedAsync(organization, request); + } + + return await UpdateCloudAsync(organization, request); + } + + private async Task UpdateCloudAsync(Organization organization, OrganizationUpdateRequest request) + { + // Store original values for comparison + var originalName = organization.Name; + var originalBillingEmail = organization.BillingEmail; + + // Apply updates to organization + organization.UpdateDetails(request); + organization.BackfillPublicPrivateKeys(request); + await organizationService.ReplaceAndUpdateCacheAsync(organization, EventType.Organization_Updated); + + // Update billing information in Stripe if required + await UpdateBillingAsync(organization, originalName, originalBillingEmail); + + return organization; + } + + /// + /// Self-host cannot update the organization details because they are set by the license file. + /// However, this command does offer a soft migration pathway for organizations without public and private keys. + /// If we remove this migration code in the future, this command and endpoint can become cloud only. + /// + private async Task UpdateSelfHostedAsync(Organization organization, OrganizationUpdateRequest request) + { + organization.BackfillPublicPrivateKeys(request); + await organizationService.ReplaceAndUpdateCacheAsync(organization, EventType.Organization_Updated); + return organization; + } + + private async Task UpdateBillingAsync(Organization organization, string originalName, string? originalBillingEmail) + { + // Update Stripe if name or billing email changed + var shouldUpdateBilling = originalName != organization.Name || + originalBillingEmail != organization.BillingEmail; + + if (!shouldUpdateBilling || string.IsNullOrWhiteSpace(organization.GatewayCustomerId)) + { + return; + } + + await organizationBillingService.UpdateOrganizationNameAndEmail(organization); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateExtensions.cs new file mode 100644 index 0000000000..e90c39bc54 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateExtensions.cs @@ -0,0 +1,43 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update; + +public static class OrganizationUpdateExtensions +{ + /// + /// Updates the organization name and/or billing email. + /// Any null property on the request object will be skipped. + /// + public static void UpdateDetails(this Organization organization, OrganizationUpdateRequest request) + { + // These values may or may not be sent by the client depending on the operation being performed. + // Skip any values not provided. + if (request.Name is not null) + { + organization.Name = request.Name; + } + + if (request.BillingEmail is not null) + { + organization.BillingEmail = request.BillingEmail.ToLowerInvariant().Trim(); + } + } + + /// + /// Updates the organization public and private keys if provided and not already set. + /// This is legacy code for old organizations that were not created with a public/private keypair. It is a soft + /// migration that will silently migrate organizations when they change their details. + /// + public static void BackfillPublicPrivateKeys(this Organization organization, OrganizationUpdateRequest request) + { + if (!string.IsNullOrWhiteSpace(request.PublicKey) && string.IsNullOrWhiteSpace(organization.PublicKey)) + { + organization.PublicKey = request.PublicKey; + } + + if (!string.IsNullOrWhiteSpace(request.EncryptedPrivateKey) && string.IsNullOrWhiteSpace(organization.PrivateKey)) + { + organization.PrivateKey = request.EncryptedPrivateKey; + } + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateRequest.cs new file mode 100644 index 0000000000..21d4948678 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateRequest.cs @@ -0,0 +1,33 @@ +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update; + +/// +/// Request model for updating the name, billing email, and/or public-private keys for an organization (legacy migration code). +/// Any combination of these properties can be updated, so they are optional. If none are specified it will not update anything. +/// +public record OrganizationUpdateRequest +{ + /// + /// The ID of the organization to update. + /// + public required Guid OrganizationId { get; init; } + + /// + /// The new organization name to apply (optional, this is skipped if not provided). + /// + public string? Name { get; init; } + + /// + /// The new billing email address to apply (optional, this is skipped if not provided). + /// + public string? BillingEmail { get; init; } + + /// + /// The organization's public key to set (optional, only set if not already present on the organization). + /// + public string? PublicKey { get; init; } + + /// + /// The organization's encrypted private key to set (optional, only set if not already present on the organization). + /// + public string? EncryptedPrivateKey { get; init; } +} diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs index e29d0eaaad..4202ba770e 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs @@ -1,11 +1,15 @@ using System.Text.Json; +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Utilities; using Bit.Core.Enums; using Bit.Core.Models.Data; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; +using Bit.Core.Utilities; using Microsoft.Extensions.Logging; +using ZiggyCreatures.Caching.Fusion; namespace Bit.Core.Services; @@ -14,6 +18,7 @@ public class EventIntegrationHandler( IEventIntegrationPublisher eventIntegrationPublisher, IIntegrationFilterService integrationFilterService, IIntegrationConfigurationDetailsCache configurationCache, + IFusionCache cache, IGroupRepository groupRepository, IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, @@ -87,13 +92,18 @@ public class EventIntegrationHandler( } } - private async Task BuildContextAsync(EventMessage eventMessage, string template) + internal async Task BuildContextAsync(EventMessage eventMessage, string template) { + // Note: All of these cache calls use the default options, including TTL of 30 minutes + var context = new IntegrationTemplateContext(eventMessage); if (IntegrationTemplateProcessor.TemplateRequiresGroup(template) && eventMessage.GroupId.HasValue) { - context.Group = await groupRepository.GetByIdAsync(eventMessage.GroupId.Value); + context.Group = await cache.GetOrSetAsync( + key: EventIntegrationsCacheConstants.BuildCacheKeyForGroup(eventMessage.GroupId.Value), + factory: async _ => await groupRepository.GetByIdAsync(eventMessage.GroupId.Value) + ); } if (eventMessage.OrganizationId is not Guid organizationId) @@ -103,25 +113,31 @@ public class EventIntegrationHandler( if (IntegrationTemplateProcessor.TemplateRequiresUser(template) && eventMessage.UserId.HasValue) { - context.User = await organizationUserRepository.GetDetailsByOrganizationIdUserIdAsync( - organizationId: organizationId, - userId: eventMessage.UserId.Value - ); + context.User = await GetUserFromCacheAsync(organizationId, eventMessage.UserId.Value); } if (IntegrationTemplateProcessor.TemplateRequiresActingUser(template) && eventMessage.ActingUserId.HasValue) { - context.ActingUser = await organizationUserRepository.GetDetailsByOrganizationIdUserIdAsync( - organizationId: organizationId, - userId: eventMessage.ActingUserId.Value - ); + context.ActingUser = await GetUserFromCacheAsync(organizationId, eventMessage.ActingUserId.Value); } if (IntegrationTemplateProcessor.TemplateRequiresOrganization(template)) { - context.Organization = await organizationRepository.GetByIdAsync(organizationId); + context.Organization = await cache.GetOrSetAsync( + key: EventIntegrationsCacheConstants.BuildCacheKeyForOrganization(organizationId), + factory: async _ => await organizationRepository.GetByIdAsync(organizationId) + ); } return context; } + + private async Task GetUserFromCacheAsync(Guid organizationId, Guid userId) => + await cache.GetOrSetAsync( + key: EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationUser(organizationId, userId), + factory: async _ => await organizationUserRepository.GetDetailsByOrganizationIdUserIdAsync( + organizationId: organizationId, + userId: userId + ) + ); } diff --git a/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs b/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs index 62df3b2bc9..7fc8013c15 100644 --- a/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs +++ b/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Text.RegularExpressions; +using System.Text.RegularExpressions; namespace Bit.Core.AdminConsole.Utilities; diff --git a/src/Core/Auth/Models/Mail/RegisterVerifyEmail.cs b/src/Core/Auth/Models/Mail/RegisterVerifyEmail.cs index fe42093111..5c0efeb73f 100644 --- a/src/Core/Auth/Models/Mail/RegisterVerifyEmail.cs +++ b/src/Core/Auth/Models/Mail/RegisterVerifyEmail.cs @@ -15,11 +15,13 @@ public class RegisterVerifyEmail : BaseMailModel // so we must land on a redirect connector which will redirect to the finish signup page. // Note 3: The use of a fragment to indicate the redirect url is to prevent the query string from being logged by // proxies and servers. It also helps reduce open redirect vulnerabilities. - public string Url => string.Format("{0}/redirect-connector.html#finish-signup?token={1}&email={2}&fromEmail=true", + public string Url => string.Format("{0}/redirect-connector.html#finish-signup?token={1}&email={2}&fromEmail=true{3}", WebVaultUrl, Token, - Email); + Email, + !string.IsNullOrEmpty(FromMarketing) ? $"&fromMarketing={FromMarketing}" : string.Empty); public string Token { get; set; } public string Email { get; set; } + public string FromMarketing { get; set; } } diff --git a/src/Core/Auth/UserFeatures/Registration/ISendVerificationEmailForRegistrationCommand.cs b/src/Core/Auth/UserFeatures/Registration/ISendVerificationEmailForRegistrationCommand.cs index b623b8cab3..2a224b9eb9 100644 --- a/src/Core/Auth/UserFeatures/Registration/ISendVerificationEmailForRegistrationCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/ISendVerificationEmailForRegistrationCommand.cs @@ -3,5 +3,5 @@ namespace Bit.Core.Auth.UserFeatures.Registration; public interface ISendVerificationEmailForRegistrationCommand { - public Task Run(string email, string? name, bool receiveMarketingEmails); + public Task Run(string email, string? name, bool receiveMarketingEmails, string? fromMarketing); } diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs index 5841cd2e62..2e8587eee6 100644 --- a/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs @@ -44,7 +44,7 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai } - public async Task Run(string email, string? name, bool receiveMarketingEmails) + public async Task Run(string email, string? name, bool receiveMarketingEmails, string? fromMarketing) { if (_globalSettings.DisableUserRegistration) { @@ -92,7 +92,7 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai // If the user doesn't exist, create a new EmailVerificationTokenable and send the user // an email with a link to verify their email address var token = GenerateToken(email, name, receiveMarketingEmails); - await _mailService.SendRegistrationVerificationEmailAsync(email, token); + await _mailService.SendRegistrationVerificationEmailAsync(email, token, fromMarketing); } // User exists but we will return a 200 regardless of whether the email was sent or not; so return null diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index 11f043fc69..c062351a91 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -65,6 +65,7 @@ public static class StripeConstants public const string Region = "region"; public const string RetiredBraintreeCustomerId = "btCustomerId_old"; public const string UserId = "userId"; + public const string StorageReconciled2025 = "storage_reconciled_2025"; } public static class PaymentBehavior diff --git a/src/Core/Billing/Organizations/Services/IOrganizationBillingService.cs b/src/Core/Billing/Organizations/Services/IOrganizationBillingService.cs index d34bd86e7b..6c7f087ffa 100644 --- a/src/Core/Billing/Organizations/Services/IOrganizationBillingService.cs +++ b/src/Core/Billing/Organizations/Services/IOrganizationBillingService.cs @@ -56,4 +56,15 @@ public interface IOrganizationBillingService /// Thrown when the is . /// Thrown when no payment method is found for the customer, no plan IDs are provided, or subscription update fails. Task UpdateSubscriptionPlanFrequency(Organization organization, PlanType newPlanType); + + /// + /// Updates the organization name and email on the Stripe customer entry. + /// This only updates Stripe, not the Bitwarden database. + /// + /// + /// The caller should ensure that the organization has a GatewayCustomerId before calling this method. + /// + /// The organization to update in Stripe. + /// Thrown when the organization does not have a GatewayCustomerId. + Task UpdateOrganizationNameAndEmail(Organization organization); } diff --git a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs index b10f04d766..65c339fad4 100644 --- a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs +++ b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs @@ -176,6 +176,35 @@ public class OrganizationBillingService( } } + public async Task UpdateOrganizationNameAndEmail(Organization organization) + { + if (organization.GatewayCustomerId is null) + { + throw new BillingException("Cannot update an organization in Stripe without a GatewayCustomerId."); + } + + var newDisplayName = organization.DisplayName(); + + await stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, + new CustomerUpdateOptions + { + Email = organization.BillingEmail, + Description = newDisplayName, + InvoiceSettings = new CustomerInvoiceSettingsOptions + { + // This overwrites the existing custom fields for this organization + CustomFields = [ + new CustomerInvoiceSettingsCustomFieldOptions + { + Name = organization.SubscriberType(), + Value = newDisplayName.Length <= 30 + ? newDisplayName + : newDisplayName[..30] + }] + }, + }); + } + #region Utilities private async Task CreateCustomerAsync( diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 0e5e7bf3ca..5d2cd54489 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -197,6 +197,8 @@ public static class FeatureFlagKeys public const string PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service"; public const string PM23341_Milestone_2 = "pm-23341-milestone-2"; public const string PM26462_Milestone_3 = "pm-26462-milestone-3"; + public const string PM28265_EnableReconcileAdditionalStorageJob = "pm-28265-enable-reconcile-additional-storage-job"; + public const string PM28265_ReconcileAdditionalStorageJobEnableLiveMode = "pm-28265-reconcile-additional-storage-job-enable-live-mode"; /* Key Management Team */ public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; @@ -242,7 +244,6 @@ public static class FeatureFlagKeys public const string ChromiumImporterWithABE = "pm-25855-chromium-importer-abe"; /* Vault Team */ - public const string PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge"; public const string CipherKeyEncryption = "cipher-key-encryption"; public const string PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk"; public const string EndUserNotifications = "pm-10609-end-user-notifications"; @@ -263,6 +264,9 @@ public static class FeatureFlagKeys public const string EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike"; public const string EventDiagnosticLogging = "pm-27666-siem-event-log-debugging"; + /* UIF Team */ + public const string RouterFocusManagement = "router-focus-management"; + public static List GetAllKeys() { return typeof(FeatureFlagKeys).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy) diff --git a/src/Core/MailTemplates/Mjml/.mjmlconfig b/src/Core/MailTemplates/Mjml/.mjmlconfig index 07e9fdf3d1..a71e3b5ee9 100644 --- a/src/Core/MailTemplates/Mjml/.mjmlconfig +++ b/src/Core/MailTemplates/Mjml/.mjmlconfig @@ -1,6 +1,7 @@ { "packages": [ "components/mj-bw-hero", + "components/mj-bw-simple-hero", "components/mj-bw-icon-row", "components/mj-bw-learn-more-footer", "emails/AdminConsole/components/mj-bw-inviter-info" diff --git a/src/Core/MailTemplates/Mjml/README.md b/src/Core/MailTemplates/Mjml/README.md index b9041c94f6..fabb393ee0 100644 --- a/src/Core/MailTemplates/Mjml/README.md +++ b/src/Core/MailTemplates/Mjml/README.md @@ -1,16 +1,15 @@ -# MJML email templating +# `MJML` email templating -This directory contains MJML templates for emails. MJML is a markup language designed to reduce the pain of coding responsive email templates. Component based development features in MJML improve code quality and reusability. +This directory contains `MJML` templates for emails. `MJML` is a markup language designed to reduce the pain of coding responsive email templates. Component-based development features in `MJML` improve code quality and reusability. -MJML stands for MailJet Markup Language. +> [!TIP] +> `MJML` stands for MailJet Markup Language. ## Implementation considerations -These `MJML` templates are compiled into HTML which will then be further consumed by our Handlebars mail service. We can continue to use this service to assign values from our View Models. This leverages the existing infrastructure. It also means we can continue to use the double brace (`{{}}`) syntax within MJML since Handlebars can be used to assign values to those `{{variables}}`. +`MJML` templates are compiled into `HTML`, and those outputs are then consumed by Handlebars to render the final email for delivery. It builds on top of our existing infrastructure and means we can continue to use the double brace (`{{}}`) syntax within `MJML`, since Handlebars will assign values to those `{{variables}}`. -There is no change on how we interact with our view models. - -There is an added step where we compile `*.mjml` to `*.html.hbs`. `*.html.hbs` is the format we use so the handlebars service can apply the variables. This build pipeline process is in progress and may need to be manually done at times. +To do this, there is an added step where we compile `*.mjml` to `*.html.hbs`. `*.html.hbs` is the format we use so the Handlebars service can apply the variables. This build pipeline process is in progress and may need to be manually done at times. ### `*.txt.hbs` @@ -37,45 +36,50 @@ npm run build:minify npm run prettier ``` -## Development +## Development process -MJML supports components and you can create your own components by adding them to `.mjmlconfig`. Components are simple JavaScript that return MJML markup based on the attributes assigned, see components/mj-bw-hero.js. The markup is not a proper object, but contained in a string. +`MJML` supports components and you can create your own components by adding them to `.mjmlconfig`. Components are simple JavaScript that return `MJML` markup based on the attributes assigned, see components/mj-bw-hero.js. The markup is not a proper object, but contained in a string. -When using MJML templating you can use the above [commands](#building-mjml-files) to compile the template and view it in a web browser. +When using `MJML` templating you can use the above [commands](#building-mjml-files) to compile the template and view it in a web browser. -Not all MJML tags have the same attributes, it is highly recommended to review the documentation on the official MJML website to understand the usages of each of the tags. +Not all `MJML` tags have the same attributes, it is highly recommended to review the documentation on the official MJML website to understand the usages of each of the tags. -### Recommended development - IMailService +### Developing the mail template -#### Mjml email template development +1. Create `cool-email.mjml` in appropriate team directory. +2. Run `npm run build:watch`. +3. View compiled `HTML` output in a web browser. +4. Iterate through your development. While running `build:watch` you should be able to refresh the browser page after the `mjml/js` recompile to see the changes. -1. create `cool-email.mjml` in appropriate team directory -2. run `npm run build:watch` -3. view compiled `HTML` output in a web browser -4. iterate -> while `build:watch`'ing you should be able to refresh the browser page after the mjml/js re-compile to see the changes +### Testing the mail template with `IMailer` -#### Testing with `IMailService` +After the email is developed in the [initial step](#developing-the-mail-template), we need to make sure that the email `{{variables}}` are populated properly by Handlebars. We can do this by running it through an `IMailer` implementation. The `IMailer`, documented [here](../../Platform/Mail/README.md#step-3-create-handlebars-templates), requires that the ViewModel, the `.html.hbs` `MJML` build artifact, and `.text.hbs` files be in the same directory. -After the email is developed from the [initial step](#mjml-email-template-development) make sure the email `{{variables}}` are populated properly by running it through an `IMailService` implementation. +1. Run `npm run build:hbs`. +2. Copy built `*.html.hbs` files from the build directory to the directory that the `IMailer` expects. All files in the `Core/MailTemplates/Mjml/out` directory should be copied to the `/src/Core/MailTemplates/Mjml` directory, ensuring that the files are in the same directory as the corresponding ViewModels. If a shared component is modified it is important to copy and overwrite all files in that directory to capture changes in the `*.html.hbs` files. +3. Run code that will send the email. -1. run `npm run build:hbs` -2. copy built `*.html.hbs` files from the build directory to a location the mail service can consume them - 1. all files in the `Core/MailTemplates/Mjml/out` directory can be copied to the `src/Core/MailTemplates/Handlebars/MJML` directory. If a shared component is modified it is important to copy and overwrite all files in that directory to capture - changes in the `*.html.hbs`. -3. run code that will send the email +The minified `html.hbs` artifacts are deliverables and must be placed into the correct `/src/Core/MailTemplates/Mjml` directories in order to be used by `IMailer` implementations, see step 2 above. + +### Testing the mail template with `IMailService` + +> [!WARNING] +> The `IMailService` has been deprecated. The [IMailer](#testing-the-mail-template-with-imailer) should be used instead. + +After the email is developed from the [initial step](#developing-the-mail-template), make sure the email `{{variables}}` are populated properly by running it through an `IMailService` implementation. + +1. Run `npm run build:hbs` +2. Copy built `*.html.hbs` files from the build directory to a location the mail service can consume them. + 1. All files in the `Core/MailTemplates/Mjml/out` directory should be copied to the `src/Core/MailTemplates/Handlebars/MJML` directory. If a shared component is modified it is important to copy and overwrite all files in that directory to capture changes in the `*.html.hbs`. +3. Run code that will send the email. The minified `html.hbs` artifacts are deliverables and must be placed into the correct `src/Core/MailTemplates/Handlebars/` directories in order to be used by `IMailService` implementations, see 2.1 above. -### Recommended development - IMailer - -TBD - PM-26475 - ### Custom tags There is currently a `mj-bw-hero` tag you can use within your `*.mjml` templates. This is a good example of how to create a component that takes in attribute values allowing us to be more DRY in our development of emails. Since the attribute's input is a string we are able to define whatever we need into the component, in this case `mj-bw-hero`. -In order to view the custom component you have written you will need to include it in the `.mjmlconfig` and reference it in an `mjml` template file. - +In order to view the custom component you have written you will need to include it in the `.mjmlconfig` and reference it in a `.mjml` template file. ```html ``` -Attributes in Custom Components are defined by the developer. They can be required or optional depending on implementation. See the official MJML documentation for more information. - +Attributes in custom components are defined by the developer. They can be required or optional depending on implementation. See the official `MJML` [documentation](https://documentation.mjml.io/#components) for more information. ```js static allowedAttributes = { "img-src": "string", // REQUIRED: Source for the image displayed in the right-hand side of the blue header area @@ -108,7 +111,7 @@ Custom components, such as `mj-bw-hero`, must be defined in the `.mjmlconfig` in ### `mj-include` -You are also able to reference other more static MJML templates in your MJML file simply by referencing the file within the MJML template. +You are also able to reference other more static `MJML` templates in your `MJML` file simply by referencing the file within the `MJML` template. ```html @@ -118,6 +121,6 @@ You are also able to reference other more static MJML templates in your MJML fil ``` #### `head.mjml` -Currently we include the `head.mjml` file in all MJML templates as it contains shared styling and formatting that ensures consistency across all email implementations. +Currently we include the `head.mjml` file in all `MJML` templates as it contains shared styling and formatting that ensures consistency across all email implementations. In the future we may deviate from this practice to support different layouts. At that time we will modify the docs with direction. diff --git a/src/Core/MailTemplates/Mjml/components/mj-bw-simple-hero.js b/src/Core/MailTemplates/Mjml/components/mj-bw-simple-hero.js new file mode 100644 index 0000000000..e7364e34b0 --- /dev/null +++ b/src/Core/MailTemplates/Mjml/components/mj-bw-simple-hero.js @@ -0,0 +1,40 @@ +const { BodyComponent } = require("mjml-core"); + +class MjBwSimpleHero extends BodyComponent { + static dependencies = { + // Tell the validator which tags are allowed as our component's parent + "mj-column": ["mj-bw-simple-hero"], + "mj-wrapper": ["mj-bw-simple-hero"], + // Tell the validator which tags are allowed as our component's children + "mj-bw-simple-hero": [], + }; + + static allowedAttributes = {}; + + static defaultAttributes = {}; + + render() { + return this.renderMJML( + ` + + + + + + `, + ); + } +} + +module.exports = MjBwSimpleHero; diff --git a/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/families-2020-renewal.mjml b/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/families-2020-renewal.mjml new file mode 100644 index 0000000000..dcf193875a --- /dev/null +++ b/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/families-2020-renewal.mjml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + Your Bitwarden Families subscription renews in 15 days. The price is updating to {{MonthlyRenewalPrice}}/month, billed annually. + + + Questions? Contact support@bitwarden.com + + + + + + + + + + + + + + + + diff --git a/src/Core/MailTemplates/Mjml/emails/invoice-upcoming.mjml b/src/Core/MailTemplates/Mjml/emails/invoice-upcoming.mjml deleted file mode 100644 index c50a5d1292..0000000000 --- a/src/Core/MailTemplates/Mjml/emails/invoice-upcoming.mjml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc semper sapien non sem tincidunt pretium ut vitae tortor. Mauris mattis id arcu in dictum. Vivamus tempor maximus elit id convallis. Pellentesque ligula nisl, bibendum eu maximus sit amet, rutrum efficitur tortor. Cras non dignissim leo, eget gravida odio. Nullam tincidunt porta fermentum. Fusce sit amet sagittis nunc. - - - - - - - - - diff --git a/src/Core/MailTemplates/README.md b/src/Core/MailTemplates/README.md index bd42b2a10f..f8ec78c1d2 100644 --- a/src/Core/MailTemplates/README.md +++ b/src/Core/MailTemplates/README.md @@ -75,4 +75,4 @@ The `IMailService` automatically uses both versions when sending emails: - Test plain text templates to ensure they're readable and convey the same message ## `*.mjml` -This is a templating language we use to increase efficiency when creating email content. See the readme within the `./mjml` directory for more comprehensive information. +This is a templating language we use to increase efficiency when creating email content. See the `MJML` [documentation](./Mjml/README.md) for more details. diff --git a/src/Core/Models/Mail/Billing/Renewal/Families2020Renewal/Families2020RenewalMailView.cs b/src/Core/Models/Mail/Billing/Renewal/Families2020Renewal/Families2020RenewalMailView.cs new file mode 100644 index 0000000000..eb7bef4322 --- /dev/null +++ b/src/Core/Models/Mail/Billing/Renewal/Families2020Renewal/Families2020RenewalMailView.cs @@ -0,0 +1,13 @@ +using Bit.Core.Platform.Mail.Mailer; + +namespace Bit.Core.Models.Mail.Billing.Renewal.Families2020Renewal; + +public class Families2020RenewalMailView : BaseMailView +{ + public required string MonthlyRenewalPrice { get; set; } +} + +public class Families2020RenewalMail : BaseMail +{ + public override string Subject { get => "Your Bitwarden Families renewal is updating"; } +} diff --git a/src/Core/Models/Mail/Billing/Renewal/Families2020Renewal/Families2020RenewalMailView.html.hbs b/src/Core/Models/Mail/Billing/Renewal/Families2020Renewal/Families2020RenewalMailView.html.hbs new file mode 100644 index 0000000000..ac6b80993c --- /dev/null +++ b/src/Core/Models/Mail/Billing/Renewal/Families2020Renewal/Families2020RenewalMailView.html.hbs @@ -0,0 +1,619 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +

+ Your Bitwarden Families renewal is updating +

+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ +
Your Bitwarden Families subscription renews in 15 days. The price is updating to {{MonthlyRenewalPrice}}/month, billed annually.
+ +
+ +
Questions? Contact support@bitwarden.com
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +

+ Learn more about Bitwarden +

+ Find user guides, product documentation, and videos on the + Bitwarden Help Center.
+ +
+ +
+ + + +
+ + + + + + + + + +
+ +
+ + +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + +
+ +

+ © 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa + Barbara, CA, USA +

+

+ Always confirm you are on a trusted Bitwarden domain before logging + in:
+ bitwarden.com | + Learn why we include this +

+ +
+ +
+ + +
+ +
+ + + + + +
+ + + diff --git a/src/Core/Models/Mail/Billing/Renewal/Families2020Renewal/Families2020RenewalMailView.text.hbs b/src/Core/Models/Mail/Billing/Renewal/Families2020Renewal/Families2020RenewalMailView.text.hbs new file mode 100644 index 0000000000..002a48cf10 --- /dev/null +++ b/src/Core/Models/Mail/Billing/Renewal/Families2020Renewal/Families2020RenewalMailView.text.hbs @@ -0,0 +1,3 @@ +Your Bitwarden Families subscription renews in 15 days. The price is updating to {{MonthlyRenewalPrice}}/month, billed annually. + +Questions? Contact support@bitwarden.com diff --git a/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.cs b/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.cs deleted file mode 100644 index aeca436dbb..0000000000 --- a/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Bit.Core.Platform.Mail.Mailer; - -namespace Bit.Core.Models.Mail.UpdatedInvoiceIncoming; - -public class UpdatedInvoiceUpcomingView : BaseMailView; - -public class UpdatedInvoiceUpcomingMail : BaseMail -{ - public override string Subject { get => "Your Subscription Will Renew Soon"; } -} diff --git a/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.html.hbs b/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.html.hbs deleted file mode 100644 index a044171fe5..0000000000 --- a/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.html.hbs +++ /dev/null @@ -1,30 +0,0 @@ -
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc semper sapien non sem tincidunt pretium ut vitae tortor. Mauris mattis id arcu in dictum. Vivamus tempor maximus elit id convallis. Pellentesque ligula nisl, bibendum eu maximus sit amet, rutrum efficitur tortor. Cras non dignissim leo, eget gravida odio. Nullam tincidunt porta fermentum. Fusce sit amet sagittis nunc.

© 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa Barbara, CA, USA

Always confirm you are on a trusted Bitwarden domain before logging in:
bitwarden.com | Learn why we include this

\ No newline at end of file diff --git a/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.text.hbs b/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.text.hbs deleted file mode 100644 index a2db92bac2..0000000000 --- a/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.text.hbs +++ /dev/null @@ -1,3 +0,0 @@ -{{#>BasicTextLayout}} - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc semper sapien non sem tincidunt pretium ut vitae tortor. Mauris mattis id arcu in dictum. Vivamus tempor maximus elit id convallis. Pellentesque ligula nisl, bibendum eu maximus sit amet, rutrum efficitur tortor. Cras non dignissim leo, eget gravida odio. Nullam tincidunt porta fermentum. Fusce sit amet sagittis nunc. -{{/BasicTextLayout}} diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 91504b0b9b..91030c5151 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -12,6 +12,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; @@ -87,6 +88,7 @@ public static class OrganizationServiceCollectionExtensions private static void AddOrganizationUpdateCommands(this IServiceCollection services) { services.AddScoped(); + services.AddScoped(); } private static void AddOrganizationEnableCommands(this IServiceCollection services) => diff --git a/src/Core/Platform/Mail/HandlebarsMailService.cs b/src/Core/Platform/Mail/HandlebarsMailService.cs index a602129886..d57ca400fd 100644 --- a/src/Core/Platform/Mail/HandlebarsMailService.cs +++ b/src/Core/Platform/Mail/HandlebarsMailService.cs @@ -78,7 +78,7 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } - public async Task SendRegistrationVerificationEmailAsync(string email, string token) + public async Task SendRegistrationVerificationEmailAsync(string email, string token, string? fromMarketing) { var message = CreateDefaultMessage("Verify Your Email", email); var model = new RegisterVerifyEmail @@ -86,7 +86,8 @@ public class HandlebarsMailService : IMailService Token = WebUtility.UrlEncode(token), Email = WebUtility.UrlEncode(email), WebVaultUrl = _globalSettings.BaseServiceUri.Vault, - SiteName = _globalSettings.SiteName + SiteName = _globalSettings.SiteName, + FromMarketing = WebUtility.UrlEncode(fromMarketing), }; await AddMessageContentAsync(message, "Auth.RegistrationVerifyEmail", model); message.MetaData.Add("SendGridBypassListManagement", true); diff --git a/src/Core/Platform/Mail/IMailService.cs b/src/Core/Platform/Mail/IMailService.cs index 16c5c312fe..e21e1a010f 100644 --- a/src/Core/Platform/Mail/IMailService.cs +++ b/src/Core/Platform/Mail/IMailService.cs @@ -38,7 +38,7 @@ public interface IMailService /// Task Task SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(User user, string familyOrganizationName); Task SendVerifyEmailEmailAsync(string email, Guid userId, string token); - Task SendRegistrationVerificationEmailAsync(string email, string token); + Task SendRegistrationVerificationEmailAsync(string email, string token, string? fromMarketing); Task SendTrialInitiationSignupEmailAsync( bool isExistingUser, string email, diff --git a/src/Core/Platform/Mail/NoopMailService.cs b/src/Core/Platform/Mail/NoopMailService.cs index da55470db3..7de48e4619 100644 --- a/src/Core/Platform/Mail/NoopMailService.cs +++ b/src/Core/Platform/Mail/NoopMailService.cs @@ -26,7 +26,7 @@ public class NoopMailService : IMailService return Task.FromResult(0); } - public Task SendRegistrationVerificationEmailAsync(string email, string hint) + public Task SendRegistrationVerificationEmailAsync(string email, string hint, string? fromMarketing) { return Task.FromResult(0); } diff --git a/src/Core/Platform/Mail/README.md b/src/Core/Platform/Mail/README.md index b5caca62be..7a3b6b87c5 100644 --- a/src/Core/Platform/Mail/README.md +++ b/src/Core/Platform/Mail/README.md @@ -1,9 +1,14 @@ # Mail Services ## `MailService` -The `MailService` and its implementation in `HandlebarsMailService` has been deprecated in favor of the `Mailer` implementation. +> [!WARNING] +> The `MailService` and its implementation in `HandlebarsMailService` has been deprecated in favor of the `Mailer` implementation. -New emails should be implemented using [MJML](../../MailTemplates/README.md) and the `Mailer`. +The `MailService` class manages **all** emails, and has multiple responsibilities, including formatting, email building (instantiation of ViewModels from variables), and deciding if a mail request should be enqueued or sent directly. + +The resulting implementation cannot be owned by a single team (since all emails are in a single class), and as a result, anyone can edit any template without the appropriate team being informed. + +To alleviate these issues, all new emails should be implemented using [MJML](../../MailTemplates/README.md) and the `Mailer`. ## `Mailer` @@ -16,20 +21,20 @@ The Mailer system consists of four main components: 1. **IMailer** - Service interface for sending emails 2. **BaseMail** - Abstract base class defining email metadata (recipients, subject, category) -3. **BaseMailView** - Abstract base class for email template view models +3. **BaseMailView** - Abstract base class for email template ViewModels 4. **IMailRenderer** - Internal interface for rendering templates (implemented by `HandlebarMailRenderer`) ### How To Use -1. Define a view model that inherits from `BaseMailView` with properties for template data -2. Create Handlebars templates (`.html.hbs` and `.text.hbs`) as embedded resources, preferably using the MJML pipeline, - `/src/Core/MailTemplates/Mjml`. -3. Define an email class that inherits from `BaseMail` with metadata like subject -4. Use `IMailer.SendEmail()` to render and send the email +1. Define a ViewModel that inherits from `BaseMailView` with properties for template data. +2. Define an email class that inherits from `BaseMail` with metadata like `Subject`. +3. Create Handlebars templates (`.html.hbs` and `.text.hbs`) as embedded resources, preferably using the `MJML` [pipeline](../../MailTemplates/Mjml/README.md#development-process), in + a directory in `/src/Core/MailTemplates/Mjml`. +4. Use `IMailer.SendEmail()` to render and send the email. ### Creating a New Email -#### Step 1: Define the Email & View Model +#### Step 1: Define the ViewModel Create a class that inherits from `BaseMailView`: @@ -43,17 +48,25 @@ public class WelcomeEmailView : BaseMailView public required string UserName { get; init; } public required string ActivationUrl { get; init; } } +``` +#### Step 2: Define the email class + +Create a class that inherits from `BaseMail`: + +```csharp public class WelcomeEmail : BaseMail { public override string Subject => "Welcome to Bitwarden"; } ``` -#### Step 2: Create Handlebars Templates +#### Step 3: Create Handlebars templates -Create two template files as embedded resources next to your view model. **Important**: The file names must be located -directly next to the `ViewClass` and match the name of the view. +Create two template files as embedded resources next to your ViewModel. + +> [!IMPORTANT] +> The files must be located directly next to the `ViewClass` and match the name of the view. **WelcomeEmailView.html.hbs** (HTML version): @@ -87,7 +100,7 @@ Activate your account: {{ ActivationUrl }} ``` -#### Step 3: Send the Email +#### Step 4: Send the email Inject `IMailer` and send the email, this may be done in a service, command or some other application layer. @@ -160,7 +173,7 @@ public class MarketingEmail : BaseMail ### Built-in View Properties -All view models inherit from `BaseMailView`, which provides: +All ViewModels inherit from `BaseMailView`, which provides: - **CurrentYear** - The current UTC year (useful for copyright notices) @@ -176,7 +189,7 @@ Templates must follow this naming convention: - HTML template: `{ViewModelFullName}.html.hbs` - Text template: `{ViewModelFullName}.text.hbs` -For example, if your view model is `Bit.Core.Auth.Models.Mail.VerifyEmailView`, the templates must be: +For example, if your ViewModel is `Bit.Core.Auth.Models.Mail.VerifyEmailView`, the templates must be: - `Bit.Core.Auth.Models.Mail.VerifyEmailView.html.hbs` - `Bit.Core.Auth.Models.Mail.VerifyEmailView.text.hbs` @@ -210,4 +223,4 @@ services.TryAddSingleton(); The mail services support loading the mail template from disk. This is intended to be used by self-hosted customers who want to modify their email appearance. These overrides are not intended to be used during local development, as any changes there would not be reflected in the templates used in a normal deployment configuration. -Any customer using this override has worked with Bitwarden support on an approved implementation and has acknowledged that they are responsible for reacting to any changes made to the templates as a part of the Bitwarden development process. This includes, but is not limited to, changes in Handlebars property names, removal of properties from the `ViewModel` classes, and changes in template names. **Bitwarden is not responsible for maintaining backward compatibility between releases in order to support any overridden emails.** \ No newline at end of file +Any customer using this override has worked with Bitwarden support on an approved implementation and has acknowledged that they are responsible for reacting to any changes made to the templates as a part of the Bitwarden development process. This includes, but is not limited to, changes in Handlebars property names, removal of properties from the ViewModel classes, and changes in template names. **Bitwarden is not responsible for maintaining backward compatibility between releases in order to support any overridden emails.** \ No newline at end of file diff --git a/src/Core/Utilities/CACHING.md b/src/Core/Utilities/CACHING.md new file mode 100644 index 0000000000..d838896cbf --- /dev/null +++ b/src/Core/Utilities/CACHING.md @@ -0,0 +1,1123 @@ +# Bitwarden Server Caching + +Caching options available in Bitwarden's server. The server uses multiple caching layers and backends to balance performance, scalability, and operational simplicity across both cloud and self-hosted deployments. + +--- + +## Choosing a Caching Option + +Use this decision tree to identify the appropriate caching option for your feature: + +``` +Does your data need to be shared across all instances in a horizontally-scaled deployment? +├─ YES +│ │ +│ Do you need long-term persistence with TTL (days/weeks)? +│ ├─ YES → Use `IDistributedCache` with persistent keyed service +│ └─ NO → Use `ExtendedCache` +│ │ +│ Notes: +│ - With Redis configured: memory + distributed + backplane +│ - Without Redis: memory-only with stampede protection +│ - Provides fail-safe, eager refresh, circuit breaker +│ - For org/provider abilities: Use GetOrSetAsync with preloading pattern +│ +└─ NO (single instance or manual sync acceptable) + │ + Use `ExtendedCache` with memory-only mode (EnableDistributedCache = false) + │ + Notes: + - Same performance as raw IMemoryCache + - Built-in stampede protection, eager refresh, fail-safe + - "Free" Redis/backplane if needed at a later date (but not required) + - Only use specialized in-memory cache if ExtendedCache API doesn't fit + +*Stampede protection = prevents cache stampedes (multiple simultaneous requests for the same expired/missing key triggering redundant backend calls) +``` + +--- + +## Caching Options Overview + +| Option | Best For | Horizontal Scale | TTL Support | Backend Options | +| -------------------------------------- | ---------------------------------------------- | ---------------- | ----------- | ---------------------- | +| **ExtendedCache** | General-purpose caching with advanced features | ✅ Yes | ✅ Yes | Redis, Memory | +| **IDistributedCache** (default) | Short-lived key-value caching | ✅ Yes | ⚠️ Manual | Redis, SQL, EF | +| **IDistributedCache** (`"persistent"`) | Long-lived data with TTL | ✅ Yes | ✅ Yes | Cosmos, Redis, SQL, EF | +| **In-Memory Cache** | High-frequency reads, single instance | ❌ No | ⚠️ Manual | Memory | + +--- + +## `ExtendedCache` + +`ExtendedCache` is a wrapper around [FusionCache](https://github.com/ZiggyCreatures/FusionCache) that provides a simple way to register **named, isolated caches** with sensible defaults. The goal is to make it trivial for each subsystem or feature to have its own cache - with optional distributed caching and backplane support - without repeatedly wiring up FusionCache, Redis, and related infrastructure. + +Each named cache automatically receives: + +- Its own `FusionCache` instance +- Its own configuration (default or overridden) +- Its own key prefix +- Optional distributed store +- Optional backplane + +`ExtendedCache` supports three deployment modes: + +- **Memory-only caching** (with stampede protection: prevents multiple concurrent requests for the same key from hitting the backend) +- **Memory + distributed cache + backplane** using the **shared** application Redis +- **Memory + distributed cache + backplane** using a **fully isolated** Redis instance + +### When to Use + +- **General-purpose caching** for any domain data +- Features requiring **stampede protection** (when multiple concurrent requests for the same cache key should result in only a single backend call, with all requesters waiting for the same result) +- Data that benefits from **fail-safe mode** (serve stale data on backend failures) +- Multi-instance applications requiring **cache synchronization** via backplane +- You want **isolated cache configuration** per feature + +### Pros + +✅ **Advanced features out-of-the-box**: + +- Stampede protection (multiple requests for same key = single backend call) +- Fail-safe mode with stale data serving +- Adaptive caching with eager refresh +- Automatic backplane support for multi-instance sync +- Circuit breaker for backend failures + +✅ **Named, isolated caches**: Each feature gets its own cache instance with independent configuration + +✅ **Flexible deployment modes**: + +- Memory-only (development, testing) +- Memory + Redis (production cloud) +- Memory + isolated Redis (specialized features) + +✅ **Simple API**: Uses `FusionCache`'s intuitive `GetOrSet` pattern + +✅ **Built-in serialization**: Automatic JSON serialization/deserialization + +### Cons + +❌ Requires understanding of `FusionCache` configuration options + +❌ Slightly more overhead than raw `IDistributedCache` + +❌ IDistributedCache dependency for multi-instance deployments (typically Redis, but degrades gracefully to memory-only) + +### Example Usage + +**Note**: When using the shared Redis cache option (which is on by default, if the Redis connection string is configured), it is expected to call `services.AddDistributedCache(globalSettings)` **before** calling `AddExtendedCache`. The idea is to set up the distributed cache in our normal pattern and then "extend" it to include more functionality. + +#### 1. Register the cache (in Startup.cs): + +```csharp +// Option 1: Use default settings with shared Redis (if available) +services.AddDistributedCache(globalSettings); +services.AddExtendedCache("MyFeatureCache", globalSettings); + +// Option 2: Memory-only mode for high-performance single-instance caching +services.AddExtendedCache("MyFeatureCache", globalSettings, new GlobalSettings.ExtendedCacheSettings +{ + EnableDistributedCache = false, // Memory-only, same performance as IMemoryCache + Duration = TimeSpan.FromHours(1), + IsFailSafeEnabled = true, + EagerRefreshThreshold = 0.9 // Refresh at 90% of TTL +}); +// When EnableDistributedCache = false: +// - Uses memory-only caching (same performance as raw IMemoryCache) +// - Still provides stampede protection, eager refresh, fail-safe +// - Redis/backplane can be enabled later by setting EnableDistributedCache = true + +// Option 3: Override default settings with Redis +services.AddExtendedCache("MyFeatureCache", globalSettings, new GlobalSettings.ExtendedCacheSettings +{ + Duration = TimeSpan.FromHours(1), + IsFailSafeEnabled = true, + FailSafeMaxDuration = TimeSpan.FromHours(2), + EagerRefreshThreshold = 0.9 // Refresh at 90% of TTL +}); + +// Option 4: Isolated Redis for specialized features +services.AddExtendedCache("SpecializedCache", globalSettings, new GlobalSettings.ExtendedCacheSettings +{ + UseSharedRedisCache = false, + Redis = new GlobalSettings.ConnectionStringSettings + { + ConnectionString = "localhost:6379,ssl=false" + } +}); +// When configured this way: +// - A dedicated IConnectionMultiplexer is created +// - A dedicated IDistributedCache is created +// - A dedicated FusionCache backplane is created +// - All three are exposed to DI as keyed services (using the cache name as service key) +``` + +#### 2. Inject and use the cache: + +A named cache is retrieved via DI using keyed services (similar to how [IHttpClientFactory](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/http-requests?view=aspnetcore-7.0#named-clients) works with named clients): + +```csharp +public class MyService +{ + private readonly IFusionCache _cache; + private readonly IItemRepository _itemRepository; + + // Option A: Inject via keyed service in constructor + public MyService( + [FromKeyedServices("MyFeatureCache")] IFusionCache cache, + IItemRepository itemRepository) + { + _cache = cache; + _itemRepository = itemRepository; + } + + // Option B: Request manually from service provider + // cache = provider.GetRequiredKeyedService(serviceKey: "MyFeatureCache") + + // Option C: Inject IFusionCacheProvider and request the named cache + // (similar to IHttpClientFactory pattern) + public MyService( + IFusionCacheProvider cacheProvider, + IItemRepository itemRepository) + { + _cache = cacheProvider.GetCache("MyFeatureCache"); + _itemRepository = itemRepository; + } + + public async Task GetItemAsync(Guid id) + { + return await _cache.GetOrSetAsync( + $"item:{id}", + async _ => await _itemRepository.GetByIdAsync(id), + options => options.SetDuration(TimeSpan.FromMinutes(30)) + ); + } +} +``` + +`ExtendedCache` doesn't change how `FusionCache` is used in code, which means all the functionality and full `FusionCache` API is available. See the [FusionCache docs](https://github.com/ZiggyCreatures/FusionCache/blob/main/docs/CoreMethods.md) for more details. + +### Specific Example: SSO Authorization Grants + +SSO authorization grants are **ephemeral, short-lived data** (typically ≤5 minutes) used to coordinate authorization flows across horizontally-scaled instances. `ExtendedCache` is ideal for this use case: + +```csharp +services.AddExtendedCache("SsoGrants", globalSettings, new GlobalSettings.ExtendedCacheSettings +{ + Duration = TimeSpan.FromMinutes(5), + IsFailSafeEnabled = false // Re-initiate flow rather than serve stale grants +}); + +public class SsoAuthorizationService +{ + private readonly IFusionCache _cache; + + public SsoAuthorizationService([FromKeyedServices("SsoGrants")] IFusionCache cache) + { + _cache = cache; + } + + public async Task GetGrantAsync(string authorizationCode) + { + return await _cache.GetOrDefaultAsync($"sso:grant:{authorizationCode}"); + } + + public async Task StoreGrantAsync(string authorizationCode, SsoGrant grant) + { + await _cache.SetAsync($"sso:grant:{authorizationCode}", grant); + } +} +``` + +**Why `ExtendedCache` for SSO grants:** + +- **Not critical if lost**: User can re-initiate SSO flow +- **Lower latency**: Redis backplane is faster than persistent storage +- **Simpler infrastructure**: Reuses existing Redis connection +- **Horizontal scaling**: Redis backplane automatically synchronizes across instances + +### Backend Configuration + +`ExtendedCache` automatically uses the configured backend: + +**Cloud (Bitwarden-hosted)**: + +1. Redis (primary, if `GlobalSettings.DistributedCache.Redis.ConnectionString` configured) +2. Memory-only (fallback if Redis unavailable) + +**Self-hosted**: + +1. Redis (if configured in `appsettings.json`) +2. SQL Server / EF Cache (if `IDistributedCache` is registered and no Redis) +3. Memory-only (default fallback) + +> **Note**: ExtendedCache works seamlessly with any `IDistributedCache` backend. In self-hosted scenarios without Redis, you can configure ExtendedCache to use SQL Server or Entity Framework cache as its distributed layer. This provides local memory caching in front of the database cache, with the option to add Redis later if needed. You won't get the backplane (cross-instance invalidation) without Redis, but you still get stampede protection, eager refresh, and fail-safe mode. + +### Specific Example: Organization/Provider Abilities + +Organization and provider abilities are read extremely frequently (on every request that checks permissions) but change infrequently. `ExtendedCache` is ideal for this access pattern with its eager refresh and Redis backplane support: + +```csharp +services.AddExtendedCache("OrganizationAbilities", globalSettings, new GlobalSettings.ExtendedCacheSettings +{ + Duration = TimeSpan.FromMinutes(10), + EagerRefreshThreshold = 0.9, // Refresh at 90% of TTL + IsFailSafeEnabled = true, + FailSafeMaxDuration = TimeSpan.FromHours(1) // Serve stale data up to 1 hour on backend failures +}); + +public class OrganizationAbilityService +{ + private readonly IFusionCache _cache; + private readonly IOrganizationRepository _organizationRepository; + + public OrganizationAbilityService( + [FromKeyedServices("OrganizationAbilities")] IFusionCache cache, + IOrganizationRepository organizationRepository) + { + _cache = cache; + _organizationRepository = organizationRepository; + } + + public async Task> GetOrganizationAbilitiesAsync() + { + return await _cache.GetOrSetAsync>( + "all-org-abilities", + async _ => + { + var abilities = await _organizationRepository.GetManyAbilitiesAsync(); + return abilities.ToDictionary(a => a.Id); + } + ); + } + + public async Task GetOrganizationAbilityAsync(Guid orgId) + { + var abilities = await GetOrganizationAbilitiesAsync(); + abilities.TryGetValue(orgId, out var ability); + return ability; + } + + public async Task UpsertOrganizationAbilityAsync(Organization organization) + { + // Update database + await _organizationRepository.ReplaceAsync(organization); + + // Invalidate cache - with Redis backplane, this broadcasts to all instances + await _cache.RemoveAsync("all-org-abilities"); + } +} +``` + +**Why `ExtendedCache` for org/provider abilities:** + +- **High-frequency reads**: Every permission check reads abilities +- **Infrequent writes**: Abilities change rarely +- **Eager refresh**: Automatically refreshes at 90% of TTL to prevent cache misses +- **Fail-safe mode**: Serves stale data if database temporarily unavailable +- **Redis backplane**: Automatically invalidates across all instances when abilities change +- **No Service Bus dependency**: Simpler infrastructure (one Redis instead of Redis + Service Bus) + +### When NOT to Use + +- **Long-term persistent data** (days/weeks) - Use `IDistributedCache` with persistent keyed service for structured TTL support +- **Custom caching logic** - If ExtendedCache's API doesn't fit your use case, consider specialized in-memory cache + +--- + +## `IDistributedCache` + +`IDistributedCache` provides two service registrations for different use cases: + +1. **Default (unnamed) service** - For ephemeral, short-lived data +2. **Persistent cache** (keyed service: `"persistent"`) - For longer-lived data with structured TTL + +### When to Use + +**Default `IDistributedCache`**: + +- **Legacy code** already using `IDistributedCache` (consider migrating to `ExtendedCache`) +- **Third-party integrations** requiring `IDistributedCache` interface +- **ASP.NET Core session storage** (framework dependency) +- You have **specific requirements** that ExtendedCache doesn't support + +> **Note**: For new code, prefer `ExtendedCache` over default `IDistributedCache`. ExtendedCache can be configured with `EnableDistributedCache = false` to use memory-only caching with the same performance as raw `IMemoryCache`, while still providing stampede protection, fail-safe, and eager refresh. + +**Persistent cache** (keyed service: `"persistent"`): + +- **Critical data where memory loss would impact users** (refresh tokens, consent grants) +- **Long-lived structured data** with automatic TTL (days to weeks) +- **Long-lived OAuth/OIDC grants** that must survive application restarts +- **Payment intents** or workflow state that spans multiple requests +- Data requiring **automatic expiration** without manual cleanup +- **Large cache datasets** that benefit from external storage (e.g., thousands of refresh tokens) + +### Pros + +✅ **Standard ASP.NET Core interface**: Widely understood, well-documented + +✅ **Multiple backend support**: Redis, SQL Server, Entity Framework, Cosmos DB + +✅ **Automatic backend selection**: Picks the right backend based on configuration + +✅ **Simple API**: Just `Get`, `Set`, `Remove`, `Refresh` + +✅ **Minimal overhead**: No additional layers beyond the backend + +✅ **Keyed services**: Separate configurations for different use cases + +### Cons + +❌ **No stampede protection**: Multiple requests = multiple backend calls + +❌ **No fail-safe mode**: Backend unavailable = cache miss + +❌ **No backplane**: Manual cache invalidation across instances + +❌ **Manual serialization**: You handle JSON serialization (or use helpers) + +❌ **Manual TTL management** (default service): Must track expiration manually + +### Example Usage: Default (Ephemeral Data) + +#### 1. Registration (already done in Api, Admin, Billing, Identity, and Notifications Startup.cs files, plus Events and EventsProcessor service collection extensions): + +```csharp +services.AddDistributedCache(globalSettings); +``` + +#### 2. Inject and use for short-lived tokens: + +```csharp +public class TwoFactorService +{ + private readonly IDistributedCache _cache; + + public TwoFactorService(IDistributedCache cache) + { + _cache = cache; + } + + public async Task GetEmailTokenAsync(Guid userId) + { + var key = $"email-2fa:{userId}"; + var cached = await _cache.GetStringAsync(key); + return cached; + } + + public async Task SetEmailTokenAsync(Guid userId, string token) + { + var key = $"email-2fa:{userId}"; + await _cache.SetStringAsync(key, token, new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) + }); + } +} +``` + +#### 3. Using JSON helpers: + +```csharp +using Bit.Core.Utilities; + +public async Task GetDataAsync(string key) +{ + return await _cache.TryGetValue(key); +} + +public async Task SetDataAsync(string key, MyData data) +{ + await _cache.SetAsync(key, data, new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) + }); +} +``` + +### Example Usage: Persistent (Long-Lived Data) + +The persistent cache is accessed via keyed service injection and is optimized for long-lived structured data with automatic TTL support. + +#### Specific Example: Payment Workflow State + +The persistent `IDistributedCache` service is appropriate for workflow state that spans multiple requests and needs automatic TTL cleanup. + +```csharp +public class SetupIntentDistributedCache( + [FromKeyedServices("persistent")] IDistributedCache distributedCache) : ISetupIntentCache +{ + public async Task Set(Guid subscriberId, string setupIntentId) + { + // Bidirectional mapping for payment flow + var bySubscriberIdCacheKey = $"setup_intent_id_for_subscriber_id_{subscriberId}"; + var bySetupIntentIdCacheKey = $"subscriber_id_for_setup_intent_id_{setupIntentId}"; + + // Note: No explicit TTL set here. Cosmos DB uses container-level TTL for automatic cleanup. + // In cloud, Cosmos TTL handles expiration. In self-hosted, the cache backend manages TTL. + await Task.WhenAll( + distributedCache.SetStringAsync(bySubscriberIdCacheKey, setupIntentId), + distributedCache.SetStringAsync(bySetupIntentIdCacheKey, subscriberId.ToString())); + } + + public async Task GetSetupIntentIdForSubscriber(Guid subscriberId) + { + var cacheKey = $"setup_intent_id_for_subscriber_id_{subscriberId}"; + return await distributedCache.GetStringAsync(cacheKey); + } + + public async Task GetSubscriberIdForSetupIntent(string setupIntentId) + { + var cacheKey = $"subscriber_id_for_setup_intent_id_{setupIntentId}"; + var value = await distributedCache.GetStringAsync(cacheKey); + if (string.IsNullOrEmpty(value) || !Guid.TryParse(value, out var subscriberId)) + { + return null; + } + return subscriberId; + } + + public async Task RemoveSetupIntentForSubscriber(Guid subscriberId) + { + var cacheKey = $"setup_intent_id_for_subscriber_id_{subscriberId}"; + await distributedCache.RemoveAsync(cacheKey); + } +} +``` + +#### Specific Example: Long-Lived OAuth Grants + +Long-lived OAuth grants (refresh tokens, consent grants, device codes) use the persistent `IDistributedCache` in **cloud** and `IGrantRepository` as a **database fallback for self-hosted** when persistent cache is not configured: + +**Cloud (Bitwarden-hosted)**: + +- Uses persistent `IDistributedCache` directly (backed by Cosmos DB) +- Automatic TTL via Cosmos DB container-level TTL + +**Self-hosted**: + +- Uses `IGrantRepository` as a database fallback when persistent cache backend is not available +- Stores grants in `Grant` database table with automatic expiration + +**Grant type recommendations:** + +| Grant Type | Lifetime | Durability Requirement | Recommended Storage | Rationale | +| ------------------------ | ------------ | ---------------------- | ------------------- | ------------------------------------------------------------------------------------------- | +| SSO authorization codes | ≤5 min | Ephemeral, can be lost | `ExtendedCache` | User can re-initiate SSO flow if code is lost; short lifetime limits exposure window | +| OIDC authorization codes | ≤5 min | Ephemeral, can be lost | `ExtendedCache` | OAuth spec allows user to retry authorization; code is single-use and short-lived | +| PKCE code verifiers | ≤5 min | Ephemeral, can be lost | `ExtendedCache` | Tied to authorization code lifecycle; can be regenerated if authorization is retried | +| Refresh tokens | Days-weeks | Must persist | Persistent cache | Losing these forces user re-authentication; critical for seamless user experience | +| Consent grants | Weeks-months | Must persist | Persistent cache | User shouldn't have to re-consent frequently; loss degrades UX and trust | +| Device codes | Days | Must persist | Persistent cache | Device flow is async; losing codes breaks pending device authorizations with no recovery UX | + +### Backend Configuration + +The backend is automatically selected based on configuration and service key: + +#### Default `IDistributedCache` (ephemeral) + +**Cloud (Bitwarden-hosted)**: + +- **Redis** only (always configured in cloud environments) + +**Self-hosted priority order**: + +1. **Redis** (if `GlobalSettings.DistributedCache.Redis.ConnectionString` is configured) +2. **SQL Server Cache table** (if database provider is SQL Server) +3. **Entity Framework Cache table** (for PostgreSQL, MySQL, SQLite) + +#### Persistent cache (keyed service: `"persistent"`) + +**Cloud (Bitwarden-hosted)**: + +1. **Cosmos DB** (if `GlobalSettings.DistributedCache.Cosmos.ConnectionString` is configured) + - Database: `cache` + - Container: `default` +2. **Falls back to Redis** + +**Self-hosted priority order**: + +1. **Redis** (if configured) +2. **SQL Server Cache table** (if database provider is SQL Server) +3. **Entity Framework Cache table** (for PostgreSQL, MySQL, SQLite) + +### Backend Details + +#### Redis + +```csharp +services.AddStackExchangeRedisCache(options => +{ + options.Configuration = globalSettings.DistributedCache.Redis.ConnectionString; +}); +``` + +**Used for**: Cloud (always), self-hosted (if configured) + +- **Pros**: Fast, horizontally scalable, battle-tested +- **Cons**: Additional infrastructure dependency (self-hosted only) +- **TTL**: Via `AbsoluteExpiration` in cache entry options + +#### SQL Server Cache Table (Self-hosted only) + +```csharp +services.AddDistributedSqlServerCache(options => +{ + options.ConnectionString = globalSettings.SqlServer.ConnectionString; + options.SchemaName = "dbo"; + options.TableName = "Cache"; +}); +``` + +**Used for**: Self-hosted deployments without Redis + +- **Pros**: No additional infrastructure, works with existing database +- **Cons**: Slower than Redis, adds load to database, less scalable +- **TTL**: Via `ExpiresAtTime` and `AbsoluteExpiration` columns + +#### Entity Framework Cache (Self-hosted only) + +```csharp +services.AddSingleton(); +``` + +**Used for**: Self-hosted deployments with PostgreSQL, MySQL, or SQLite + +- **Pros**: Works with any EF-supported database (PostgreSQL, MySQL, SQLite) +- **Cons**: Slower than Redis, requires periodic expiration scanning, adds DB load + +**Features**: + +- Thread-safe operations with mutex locks +- Automatic expiration scanning every 30 minutes +- Sliding and absolute expiration support +- Provider-specific duplicate key handling + +**TTL**: Via `ExpiresAtTime` and `AbsoluteExpiration` columns with background scanning + +#### Cosmos DB (Cloud only, persistent cache) + +```csharp +services.AddKeyedSingleton("persistent", (provider, _) => +{ + return new CosmosCache(new CosmosCacheOptions + { + DatabaseName = "cache", + ContainerName = "default", + ClientBuilder = cosmosClientBuilder + }); +}); +``` + +**Used for**: Cloud persistent keyed service only + +- **Pros**: Globally distributed, automatic TTL support via container-level TTL, optimized for long-lived data +- **Cons**: Cloud-only, higher latency than Redis + +**TTL**: Cosmos DB container-level TTL (automatic cleanup, no scanning required) + +### Comparison: Default vs Persistent + +| Characteristic | Default | Persistent cache (`"persistent"`) | +| ----------------------- | ------------------------------ | ---------------------------------------------- | +| **Primary Use Case** | Ephemeral tokens, session data | Long-lived grants, workflow state | +| **Typical TTL** | 5-15 minutes | Hours to weeks | +| **User Impact if Lost** | Low (user can retry) | High (forces re-auth, interrupts workflows) | +| **Scale Consideration** | Small datasets | Large/growing datasets (thousands to millions) | +| **Cloud Backend** | Redis | Cosmos DB → Redis | +| **Self-Hosted Backend** | Redis → SQL → EF | Redis → SQL → EF | +| **Automatic Cleanup** | Manual expiration | Automatic TTL (Cosmos) | +| **Data Structure** | Simple key-value | Supports structured data | +| **Example** | 2FA codes, TOTP tokens | Refresh tokens, payment intents | + +### Choosing Default vs Persistent + +**Use Default when**: + +- Data lifetime < 15 minutes +- Ephemeral authentication tokens +- Simple key-value pairs +- Cost optimization is important +- Data loss on restart is acceptable + +**Use Persistent when**: + +- **Data loss would have user impact** (e.g., losing refresh tokens forces re-authentication) +- Data lifetime > 15 minutes +- **Cache size is large or growing** (thousands of items that exceed memory constraints) +- Structured data with relationships +- Automatic TTL cleanup is required +- Data must survive restarts and deployments +- Query capabilities are needed (via Cosmos DB) + +### When NOT to Use + +- **New general-purpose caching** - Use `ExtendedCache` instead for stampede protection, fail-safe, and backplane support +- **Organization/Provider abilities** - Use `ExtendedCache` with preloading pattern (see example above) +- **Short-lived ephemeral data** without persistence requirements - Use `ExtendedCache` (simpler, more features) + +--- + +## `IApplicationCacheService` (Deprecated) + +> **⚠️ Deprecated**: This service is being phased out in favor of `ExtendedCache`. New code should use `ExtendedCache` with the preloading pattern shown in the [Organization/Provider Abilities example](#specific-example-organizationprovider-abilities) above. + +### Background + +`IApplicationCacheService` was a **highly domain-specific caching service** built for Bitwarden organization and provider abilities. It used in-memory cache with Azure Service Bus for cross-instance invalidation. + +**Why it's being replaced:** + +- **Infrastructure complexity**: Required both Redis and Azure Service Bus +- **Limited applicability**: Only worked for org/provider abilities +- **Maintenance burden**: Custom implementation instead of leveraging standard caching primitives +- **Better alternative exists**: `ExtendedCache` with Redis backplane provides the same functionality with simpler infrastructure + +### Migration Path + +**Old approach** (IApplicationCacheService): + +- In-memory cache with periodic refresh +- Azure Service Bus for cross-instance invalidation +- Custom implementation for each domain + +**New approach** (ExtendedCache): + +- Memory + Redis distributed cache with backplane +- Eager refresh for automatic background updates +- Fail-safe mode for resilience +- Standard FusionCache API +- One Redis instance instead of Redis + Service Bus + +See the [Organization/Provider Abilities example](#specific-example-organizationprovider-abilities) for the recommended migration pattern. + +### When NOT to Use + +❌ **Do not use for new code** - Use `ExtendedCache` instead + +For existing code using `IApplicationCacheService`, plan migration to `ExtendedCache` using the pattern shown above. + +--- + +## Specialized In-Memory Cache + +> **Recommendation**: In most cases, use `ExtendedCache` with `EnableDistributedCache = false` instead of implementing a specialized in-memory cache. ExtendedCache provides the same memory-only performance with built-in stampede protection, eager refresh, and fail-safe capabilities. + +### When to Use + +Use a specialized in-memory cache only when: + +- **ExtendedCache's API doesn't fit** your specific use case +- **Custom eviction logic** is required beyond TTL-based expiration +- **Non-standard data structures** (e.g., priority queues, LRU with custom scoring) +- **Direct memory access patterns** that bypass serialization entirely + +For general high-performance caching, prefer `ExtendedCache` with memory-only mode. + +### Pros + +✅ **Maximum performance**: No serialization, no network calls, no locking overhead + +✅ **Simple implementation**: Just a `Dictionary` or `ConcurrentDictionary` + +✅ **Zero infrastructure**: No Redis, no database, no additional dependencies + +### Cons + +❌ **No horizontal scaling**: Each instance has separate cache state + +❌ **Manual invalidation**: No built-in cache invalidation mechanism + +❌ **Manual TTL**: You implement expiration logic + +❌ **Memory pressure**: Large datasets can cause GC issues + +### Example Implementation + +#### Simple in-memory cache: + +```csharp +public class MyFeatureCache +{ + private readonly ConcurrentDictionary> _cache = new(); + private readonly TimeSpan _defaultExpiration = TimeSpan.FromMinutes(30); + + public MyData GetOrAdd(string key, Func factory) + { + var entry = _cache.GetOrAdd(key, _ => new CacheEntry + { + Value = factory(), + ExpiresAt = DateTime.UtcNow + _defaultExpiration + }); + + // WARNING: This implementation has a race condition. Multiple threads detecting + // expiration simultaneously may each call TryRemove and then recursively call + // GetOrAdd, potentially causing the factory to execute multiple times. For + // production use cases requiring thread-safe expiration, consider using + // IMemoryCache with GetOrCreateAsync or ExtendedCache with stampede protection. + if (entry.ExpiresAt < DateTime.UtcNow) + { + _cache.TryRemove(key, out _); + return GetOrAdd(key, factory); + } + + return entry.Value; + } + + private class CacheEntry + { + public T Value { get; set; } + public DateTime ExpiresAt { get; set; } + } +} +``` + +#### Using `IMemoryCache`: + +```csharp +public class MyService +{ + private readonly IMemoryCache _memoryCache; + + public MyService(IMemoryCache memoryCache) + { + _memoryCache = memoryCache; + } + + public async Task GetDataAsync(string key) + { + return await _memoryCache.GetOrCreateAsync(key, async entry => + { + entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30); + entry.SetPriority(CacheItemPriority.High); + + return await _repository.GetDataAsync(key); + }); + } +} +``` + +### When NOT to Use + +- **Most general-purpose caching** - Use `ExtendedCache` with memory-only mode instead +- **Data requiring stampede protection** - Use `ExtendedCache` +- **Multi-instance deployments** requiring consistency - Use `ExtendedCache` with Redis +- **Long-lived OAuth grants** - Use persistent `IDistributedCache` + +> **Important**: Before implementing a custom in-memory cache, first try `ExtendedCache` with `EnableDistributedCache = false`. This gives you memory-only performance with automatic stampede protection, eager refresh, and fail-safe mode. + +--- + +## Backend Configuration + +### Configuration Priority + +The following table shows how different caching options resolve to storage backends based on configuration: + +| Cache Option | Cloud Backend | Self-Hosted Backend | Config Setting | +| -------------------------------------- | ------------------------- | --------------------------- | --------------------------------------------------------- | +| **ExtendedCache** | Redis → Memory | Redis → Memory | `GlobalSettings.DistributedCache.Redis.ConnectionString` | +| **IDistributedCache** (default) | Redis | Redis → SQL → EF | `GlobalSettings.DistributedCache.Redis.ConnectionString` | +| **IDistributedCache** (`"persistent"`) | Cosmos → Redis | Redis → SQL → EF | `GlobalSettings.DistributedCache.Cosmos.ConnectionString` | +| **OAuth Grants** (long-lived) | Persistent cache (Cosmos) | `IGrantRepository` (SQL/EF) | Various (see above) | + +### Redis Configuration + +**Cloud (Bitwarden-hosted)**: + +```json +{ + "GlobalSettings": { + "DistributedCache": { + "Redis": { + "ConnectionString": "redis.example.com:6379,ssl=true,password=..." + } + } + } +} +``` + +**Self-hosted** (`appsettings.json`): + +```json +{ + "globalSettings": { + "distributedCache": { + "redis": { + "connectionString": "localhost:6379" + } + } + } +} +``` + +### Cosmos DB Configuration + +**Persistent `IDistributedCache`** (cloud only): + +```json +{ + "GlobalSettings": { + "DistributedCache": { + "Cosmos": { + "ConnectionString": "AccountEndpoint=https://...;AccountKey=..." + } + } + } +} +``` + +- Database: `cache` +- Container: `default` +- Used for long-lived grants in cloud deployments + +### SQL Server Cache + +**Automatic configuration** (if SQL Server is database provider): + +```json +{ + "globalSettings": { + "sqlServer": { + "connectionString": "Server=...;Database=...;User Id=...;Password=..." + } + } +} +``` + +- Schema: `dbo` +- Table: `Cache` +- Migrations: Applied automatically + +### Entity Framework Cache + +**Automatic fallback** for PostgreSQL, MySQL, SQLite: + +No additional configuration required. Uses existing database connection. + +- Table: `Cache` +- Migrations: Applied automatically + +--- + +## Performance Considerations + +### Performance Characteristics + +| Backend | Read Latency | Write Latency | Throughput | +| -------------------- | ------------ | ------------- | ------------- | +| **Memory** | <1ms | <1ms | >100K req/s | +| **Redis** | 1-5ms | 1-5ms | 10K-50K req/s | +| **SQL Server** | 5-20ms | 10-50ms | 1K-5K req/s | +| **Entity Framework** | 5-20ms | 10-50ms | 1K-5K req/s | +| **Cosmos DB** | 5-15ms | 5-15ms | 10K+ req/s | + +**Note**: Latencies represent typical p95 values in production environments. Redis latencies assume same-datacenter deployment and include serialization overhead. Actual performance varies based on network topology, data size, and load. + +### Recommendations + +**For high-frequency reads (>1K req/s)**: + +1. `ExtendedCache` with Redis (cloud) +2. `ExtendedCache` memory-only (self-hosted, single instance) +3. Specialized in-memory cache (extreme performance requirements) + +**For moderate traffic (100-1K req/s)**: + +1. `ExtendedCache` with shared Redis +2. `IDistributedCache` with SQL Server cache + +**For low traffic (<100 req/s)**: + +1. `IDistributedCache` with SQL Server / EF cache +2. `ExtendedCache` memory-only + +--- + +## Testing Caches + +### Unit Testing + +**`ExtendedCache`**: + +```csharp +[Fact] +public async Task TestCacheHit() +{ + var services = new ServiceCollection(); + services.AddMemoryCache(); + services.AddExtendedCache("TestCache", new GlobalSettings + { + DistributedCache = new GlobalSettings.DistributedCacheSettings() + }); + + var provider = services.BuildServiceProvider(); + var cache = provider.GetRequiredKeyedService("TestCache"); + + await cache.SetAsync("key", "value"); + var result = await cache.GetOrDefaultAsync("key"); + + Assert.Equal("value", result); +} +``` + +**`IDistributedCache`**: + +```csharp +[Fact] +public async Task TestDistributedCache() +{ + var cache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions())); + + await cache.SetStringAsync("key", "value"); + var result = await cache.GetStringAsync("key"); + + Assert.Equal("value", result); +} +``` + +### Integration Testing + +**Example**: + +```csharp +[DatabaseTheory, DatabaseData] +public async Task Cache_ExpirationScanning_RemovesExpiredItems(IDistributedCache cache) +{ + // Set item with 1-second expiration + await cache.SetAsync("key", Encoding.UTF8.GetBytes("value"), new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(1) + }); + + // Wait for expiration + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Trigger expiration scan + var entityCache = cache as EntityFrameworkCache; + await entityCache.ScanForExpiredItemsAsync(); + + // Verify item is removed + var result = await cache.GetAsync("key"); + Assert.Null(result); +} +``` + +--- + +## Migration Examples + +Examples of migrating from one caching option to another: + +### From `IDistributedCache` → `ExtendedCache` + +**Before**: + +```csharp +// Registration +services.AddDistributedCache(globalSettings); + +// Constructor +public MyService(IDistributedCache cache, IRepository repository) +{ + _cache = cache; + _repository = repository; +} + +// Usage +public async Task GetDataAsync(string key) +{ + var data = await _cache.TryGetValue(key); + if (data == null) + { + data = await _repository.GetAsync(key); + await _cache.SetAsync(key, data, new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) + }); + } + return data; +} +``` + +**After**: + +```csharp +// Registration +services.AddDistributedCache(globalSettings); +services.AddExtendedCache("MyFeature", globalSettings); + +// Constructor +public MyService( + [FromKeyedServices("MyFeature")] IFusionCache cache, + IRepository repository) +{ + _cache = cache; + _repository = repository; +} + +// Usage +public async Task GetDataAsync(string key) +{ + return await _cache.GetOrSetAsync( + key, + async _ => await _repository.GetAsync(key), + options => options.SetDuration(TimeSpan.FromMinutes(30)) + ); +} +``` + +### From In-Memory → `ExtendedCache` + +**Before**: + +```csharp +// Field +private readonly ConcurrentDictionary _cache = new(); +private readonly IRepository _repository; + +// Constructor +public MyService(IRepository repository) +{ + _repository = repository; +} + +// Usage +public async Task GetDataAsync(string key) +{ + if (_cache.TryGetValue(key, out var cached)) + { + return cached; + } + + var data = await _repository.GetAsync(key); + _cache.TryAdd(key, data); + return data; +} +``` + +**After**: + +```csharp +// Registration +services.AddExtendedCache("MyFeature", globalSettings); + +// Constructor +public MyService( + [FromKeyedServices("MyFeature")] IFusionCache cache, + IRepository repository) +{ + _cache = cache; + _repository = repository; +} + +// Usage +public async Task GetDataAsync(string key) +{ + return await _cache.GetOrSetAsync( + key, + async _ => await _repository.GetAsync(key) + ); +} +``` diff --git a/src/Core/Utilities/EventIntegrationsCacheConstants.cs b/src/Core/Utilities/EventIntegrationsCacheConstants.cs new file mode 100644 index 0000000000..f3ba99fd12 --- /dev/null +++ b/src/Core/Utilities/EventIntegrationsCacheConstants.cs @@ -0,0 +1,52 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; + +namespace Bit.Core.Utilities; + +/// +/// Provides cache key generation helpers and cache name constants for event integration–related entities. +/// +public static class EventIntegrationsCacheConstants +{ + /// + /// The base cache name used for storing event integration data. + /// + public static readonly string CacheName = "EventIntegrations"; + + /// + /// Builds a deterministic cache key for a . + /// + /// The unique identifier of the group. + /// + /// A cache key for this Group. + /// + public static string BuildCacheKeyForGroup(Guid groupId) + { + return $"Group:{groupId:N}"; + } + + /// + /// Builds a deterministic cache key for an . + /// + /// The unique identifier of the organization. + /// + /// A cache key for the Organization. + /// + public static string BuildCacheKeyForOrganization(Guid organizationId) + { + return $"Organization:{organizationId:N}"; + } + + /// + /// Builds a deterministic cache key for an organization user . + /// + /// The unique identifier of the organization to which the user belongs. + /// The unique identifier of the user. + /// + /// A cache key for the user. + /// + public static string BuildCacheKeyForOrganizationUser(Guid organizationId, Guid userId) + { + return $"OrganizationUserUserDetails:{organizationId:N}:{userId:N}"; + } +} diff --git a/src/Core/Utilities/README.md b/src/Core/Utilities/README.md deleted file mode 100644 index d2de7bf84f..0000000000 --- a/src/Core/Utilities/README.md +++ /dev/null @@ -1,157 +0,0 @@ -## Extended Cache - -`ExtendedCache` is a wrapper around [FusionCache](https://github.com/ZiggyCreatures/FusionCache) -that provides a simple way to register **named, isolated caches** with sensible defaults. -The goal is to make it trivial for each subsystem or feature to have its own cache - -with optional distributed caching and backplane support - without repeatedly wiring up -FusionCache, Redis, and related infrastructure. - -Each named cache automatically receives: - -- Its own `FusionCache` instance -- Its own configuration (default or overridden) -- Its own key prefix -- Optional distributed store -- Optional backplane - -`ExtendedCache` supports several deployment modes: - -- **Memory-only caching** (with stampede protection) -- **Memory + distributed cache + backplane** using the **shared** application Redis -- **Memory + distributed cache + backplane** using a **fully isolated** Redis instance - -**Note**: When using the shared Redis cache option (which is on by default, if the -Redis connection string is configured), it is expected to call -`services.AddDistributedCache(globalSettings)` **before** calling -`AddExtendedCache`. The idea is to set up the distributed cache in our normal pattern -and then "extend" it to include more functionality. - -### Configuration - -`ExtendedCache` exposes a set of default properties that define how each named cache behaves. -These map directly to FusionCache configuration options such as timeouts, duration, -jitter, fail-safe mode, etc. Any cache can override these defaults independently. - -#### Default configuration - -The simplest approach registers a new named cache with default settings and reusing -the existing distributed cache: - -``` csharp - services.AddDistributedCache(globalSettings); - services.AddExtendedCache(cacheName, globalSettings); -``` - -By default: - - If `GlobalSettings.DistributedCache.Redis.ConnectionString` is configured: - - The cache is memory + distributed (Redis) - - The Redis cache created by `AddDistributedCache` is re-used - - A Redis backplane is configured, re-using the same multiplexer - - If Redis is **not** configured the cache automatically falls back to memory-only - -#### Overriding default properties - -A number of default properties are provided (see -`GlobalSettings.DistributedCache.DefaultExtendedCache` for specific values). A named -cache can override any (or all) of these properties simply by providing its own -instance of `ExtendedCacheSettings`: - -``` csharp - services.AddExtendedCache(cacheName, globalSettings, new GlobalSettings.ExtendedCacheSettings - { - Duration = TimeSpan.FromHours(1), - }); -``` - -This example keeps all other defaults—including shared Redis—but changes the -default cached item duration from 30 minutes to 1 hour. - -#### Isolated Redis configuration - -ExtendedCache can also run in a fully isolated mode where the cache uses its own: - - Redis multiplexer - - Distributed cache - - Backplane - -To enable this, specify a Redis connection string and set `UseSharedRedisCache` -to `false`: - -``` csharp - services.AddExtendedCache(cacheName, globalSettings, new GlobalSettings.ExtendedCacheSettings - { - UseSharedRedisCache = false, - Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "localhost:6379" } - }); -``` - -When configured this way: - - A dedicated `IConnectionMultiplexer` is created - - A dedicated `IDistributedCache` is created - - A dedicated FusionCache backplane is created - - All three are exposed to DI as keyed services (using the cache name as service key) - -### Accessing a named cache - -A named cache can be retrieved either: - - Directly via DI using keyed services - - Through `IFusionCacheProvider` (similar to - [IHttpClientFactory](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/http-requests?view=aspnetcore-7.0#named-clients)) - -#### Keyed service - -In the consuming class, declare an IFusionCache field: - -```csharp - private IFusionCache _cache; -``` - -Then ask DI to inject the keyed cache: - -```csharp - public MyService([FromKeyedServices("MyCache")] IFusionCache cache) - { - _cache = cache; - } -``` - -Or request it manually: - -```csharp - cache: provider.GetRequiredKeyedService(serviceKey: cacheName) -``` - -#### Injecting a provider - -Alternatively, an `IFusionCacheProvider` can be injected and used to request a named -cache - similar to how `IHttpClientFactory` can be used to create named `HttpClient` -instances - -In the class using the cache, use an injected provider to request the named cache: - -```csharp - private readonly IFusionCache _cache; - - public MyController(IFusionCacheProvider cacheProvider) - { - _cache = cacheProvider.GetCache("CacheName"); - } -``` - -### Using a cache - -Using the cache in code is as simple as replacing the direct repository calls with -`FusionCache`'s `GetOrSet` call. If the class previously fetched an `Item` from -an `ItemRepository`, all that we need to do is provide a key and the original -repository call as the fallback: - -```csharp - var item = _cache.GetOrSet( - $"item:{id}", - _ => _itemRepository.GetById(id) - ); -``` - -`ExtendedCache` doesn’t change how `FusionCache` is used in code, which means all -the functionality and full `FusionCache` API is available. See the -[FusionCache docs](https://github.com/ZiggyCreatures/FusionCache/blob/main/docs/CoreMethods.md) -for more details. diff --git a/src/Core/Utilities/RequireLowerEnvironmentAttribute.cs b/src/Core/Utilities/RequireLowerEnvironmentAttribute.cs new file mode 100644 index 0000000000..a8208844a8 --- /dev/null +++ b/src/Core/Utilities/RequireLowerEnvironmentAttribute.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Hosting; + +namespace Bit.Core.Utilities; + +/// +/// Authorization attribute that restricts controller/action access to Development and QA environments only. +/// Returns 404 Not Found in all other environments. +/// +public class RequireLowerEnvironmentAttribute() : TypeFilterAttribute(typeof(LowerEnvironmentFilter)) +{ + private class LowerEnvironmentFilter(IWebHostEnvironment environment) : IAuthorizationFilter + { + public void OnAuthorization(AuthorizationFilterContext context) + { + if (!environment.IsDevelopment() && !environment.IsEnvironment("QA")) + { + context.Result = new NotFoundResult(); + } + } + } +} diff --git a/src/Identity/Controllers/AccountsController.cs b/src/Identity/Controllers/AccountsController.cs index 108efe79ba..b7d4342c1b 100644 --- a/src/Identity/Controllers/AccountsController.cs +++ b/src/Identity/Controllers/AccountsController.cs @@ -109,8 +109,12 @@ public class AccountsController : Controller [HttpPost("register/send-verification-email")] public async Task PostRegisterSendVerificationEmail([FromBody] RegisterSendVerificationEmailRequestModel model) { + // Only pass fromMarketing if the feature flag is enabled + var isMarketingFeatureEnabled = _featureService.IsEnabled(FeatureFlagKeys.MarketingInitiatedPremiumFlow); + var fromMarketing = isMarketingFeatureEnabled ? model.FromMarketing : null; + var token = await _sendVerificationEmailForRegistrationCommand.Run(model.Email, model.Name, - model.ReceiveMarketingEmails); + model.ReceiveMarketingEmails, fromMarketing); if (token != null) { diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 9caa37b997..ad2cc0e8fa 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -86,6 +86,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StackExchange.Redis; +using ZiggyCreatures.Caching.Fusion; using NoopRepos = Bit.Core.Repositories.Noop; using Role = Bit.Core.Entities.Role; using TableStorageRepos = Bit.Core.Repositories.TableStorage; @@ -890,6 +891,7 @@ public static class ServiceCollectionExtensions eventIntegrationPublisher: provider.GetRequiredService(), integrationFilterService: provider.GetRequiredService(), configurationCache: provider.GetRequiredService(), + cache: provider.GetRequiredKeyedService(EventIntegrationsCacheConstants.CacheName), groupRepository: provider.GetRequiredService(), organizationRepository: provider.GetRequiredService(), organizationUserRepository: provider.GetRequiredService(), @@ -934,6 +936,8 @@ public static class ServiceCollectionExtensions GlobalSettings globalSettings) { // Add common services + services.AddDistributedCache(globalSettings); + services.AddExtendedCache(EventIntegrationsCacheConstants.CacheName, globalSettings); services.TryAddSingleton(); services.TryAddSingleton(provider => provider.GetRequiredService()); @@ -1018,6 +1022,7 @@ public static class ServiceCollectionExtensions eventIntegrationPublisher: provider.GetRequiredService(), integrationFilterService: provider.GetRequiredService(), configurationCache: provider.GetRequiredService(), + cache: provider.GetRequiredKeyedService(EventIntegrationsCacheConstants.CacheName), groupRepository: provider.GetRequiredService(), organizationRepository: provider.GetRequiredService(), organizationUserRepository: provider.GetRequiredService(), diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_CreateWithCollections.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_CreateWithCollections.sql index ee7e00b32a..6082e89efc 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_CreateWithCollections.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_CreateWithCollections.sql @@ -30,4 +30,10 @@ BEGIN DECLARE @UpdateCollectionsSuccess INT EXEC @UpdateCollectionsSuccess = [dbo].[Cipher_UpdateCollections] @Id, @UserId, @OrganizationId, @CollectionIds + + -- Bump the account revision date AFTER collections are assigned. + IF @UpdateCollectionsSuccess = 0 + BEGIN + EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId + END END diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationsControllerTests.cs new file mode 100644 index 0000000000..c234e77bc8 --- /dev/null +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -0,0 +1,196 @@ +using System.Net; +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.Billing.Enums; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Xunit; + +namespace Bit.Api.IntegrationTest.AdminConsole.Controllers; + +public class OrganizationsControllerTests : IClassFixture, IAsyncLifetime +{ + private readonly HttpClient _client; + private readonly ApiApplicationFactory _factory; + private readonly LoginHelper _loginHelper; + + private Organization _organization = null!; + private string _ownerEmail = null!; + private readonly string _billingEmail = "billing@example.com"; + private readonly string _organizationName = "Organizations Controller Test Org"; + + public OrganizationsControllerTests(ApiApplicationFactory apiFactory) + { + _factory = apiFactory; + _client = _factory.CreateClient(); + _loginHelper = new LoginHelper(_factory, _client); + } + + public async Task InitializeAsync() + { + _ownerEmail = $"org-integration-test-{Guid.NewGuid()}@example.com"; + await _factory.LoginWithNewAccount(_ownerEmail); + + (_organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, + name: _organizationName, + billingEmail: _billingEmail, + plan: PlanType.EnterpriseAnnually, + ownerEmail: _ownerEmail, + passwordManagerSeats: 5, + paymentMethod: PaymentMethodType.Card); + } + + public Task DisposeAsync() + { + _client.Dispose(); + return Task.CompletedTask; + } + + [Fact] + public async Task Put_AsOwner_WithoutProvider_CanUpdateOrganization() + { + // Arrange - Regular organization owner (no provider) + await _loginHelper.LoginAsync(_ownerEmail); + + var updateRequest = new OrganizationUpdateRequestModel + { + Name = "Updated Organization Name", + BillingEmail = "newbillingemail@example.com" + }; + + // Act + var response = await _client.PutAsJsonAsync($"/organizations/{_organization.Id}", updateRequest); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // Verify the organization name was updated + var organizationRepository = _factory.GetService(); + var updatedOrg = await organizationRepository.GetByIdAsync(_organization.Id); + Assert.NotNull(updatedOrg); + Assert.Equal("Updated Organization Name", updatedOrg.Name); + Assert.Equal("newbillingemail@example.com", updatedOrg.BillingEmail); + } + + [Fact] + public async Task Put_AsProvider_CanUpdateOrganization() + { + // Create and login as a new account to be the provider user (not the owner) + var providerUserEmail = $"provider-{Guid.NewGuid()}@example.com"; + var (token, _) = await _factory.LoginWithNewAccount(providerUserEmail); + + // Set up provider linked to org and ProviderUser entry + var provider = await ProviderTestHelpers.CreateProviderAndLinkToOrganizationAsync(_factory, _organization.Id, + ProviderType.Msp); + await ProviderTestHelpers.CreateProviderUserAsync(_factory, provider.Id, providerUserEmail, + ProviderUserType.ProviderAdmin); + + await _loginHelper.LoginAsync(providerUserEmail); + + var updateRequest = new OrganizationUpdateRequestModel + { + Name = "Updated Organization Name", + BillingEmail = "newbillingemail@example.com" + }; + + // Act + var response = await _client.PutAsJsonAsync($"/organizations/{_organization.Id}", updateRequest); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // Verify the organization name was updated + var organizationRepository = _factory.GetService(); + var updatedOrg = await organizationRepository.GetByIdAsync(_organization.Id); + Assert.NotNull(updatedOrg); + Assert.Equal("Updated Organization Name", updatedOrg.Name); + Assert.Equal("newbillingemail@example.com", updatedOrg.BillingEmail); + } + + [Fact] + public async Task Put_NotMemberOrProvider_CannotUpdateOrganization() + { + // Create and login as a new account to be unrelated to the org + var userEmail = "stranger@example.com"; + await _factory.LoginWithNewAccount(userEmail); + await _loginHelper.LoginAsync(userEmail); + + var updateRequest = new OrganizationUpdateRequestModel + { + Name = "Updated Organization Name", + BillingEmail = "newbillingemail@example.com" + }; + + // Act + var response = await _client.PutAsJsonAsync($"/organizations/{_organization.Id}", updateRequest); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + + // Verify the organization name was not updated + var organizationRepository = _factory.GetService(); + var updatedOrg = await organizationRepository.GetByIdAsync(_organization.Id); + Assert.NotNull(updatedOrg); + Assert.Equal(_organizationName, updatedOrg.Name); + Assert.Equal(_billingEmail, updatedOrg.BillingEmail); + } + + [Fact] + public async Task Put_AsOwner_WithProvider_CanRenameOrganization() + { + // Arrange - Create provider and link to organization + // The active user is ONLY an org owner, NOT a provider user + await ProviderTestHelpers.CreateProviderAndLinkToOrganizationAsync(_factory, _organization.Id, ProviderType.Msp); + await _loginHelper.LoginAsync(_ownerEmail); + + var updateRequest = new OrganizationUpdateRequestModel + { + Name = "Updated Organization Name", + BillingEmail = null + }; + + // Act + var response = await _client.PutAsJsonAsync($"/organizations/{_organization.Id}", updateRequest); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // Verify the organization name was actually updated + var organizationRepository = _factory.GetService(); + var updatedOrg = await organizationRepository.GetByIdAsync(_organization.Id); + Assert.NotNull(updatedOrg); + Assert.Equal("Updated Organization Name", updatedOrg.Name); + Assert.Equal(_billingEmail, updatedOrg.BillingEmail); + } + + [Fact] + public async Task Put_AsOwner_WithProvider_CannotChangeBillingEmail() + { + // Arrange - Create provider and link to organization + // The active user is ONLY an org owner, NOT a provider user + await ProviderTestHelpers.CreateProviderAndLinkToOrganizationAsync(_factory, _organization.Id, ProviderType.Msp); + await _loginHelper.LoginAsync(_ownerEmail); + + var updateRequest = new OrganizationUpdateRequestModel + { + Name = "Updated Organization Name", + BillingEmail = "updatedbilling@example.com" + }; + + // Act + var response = await _client.PutAsJsonAsync($"/organizations/{_organization.Id}", updateRequest); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + + // Verify the organization was not updated + var organizationRepository = _factory.GetService(); + var updatedOrg = await organizationRepository.GetByIdAsync(_organization.Id); + Assert.NotNull(updatedOrg); + Assert.Equal(_organizationName, updatedOrg.Name); + Assert.Equal(_billingEmail, updatedOrg.BillingEmail); + } +} diff --git a/test/Api.IntegrationTest/Helpers/ProviderTestHelpers.cs b/test/Api.IntegrationTest/Helpers/ProviderTestHelpers.cs new file mode 100644 index 0000000000..ab52bcd076 --- /dev/null +++ b/test/Api.IntegrationTest/Helpers/ProviderTestHelpers.cs @@ -0,0 +1,77 @@ +using Bit.Api.IntegrationTest.Factories; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Repositories; + +namespace Bit.Api.IntegrationTest.Helpers; + +public static class ProviderTestHelpers +{ + /// + /// Creates a provider and links it to an organization. + /// This does NOT create any provider users. + /// + /// The API application factory + /// The organization ID to link to the provider + /// The type of provider to create + /// The provider status (defaults to Created) + /// The created provider + public static async Task CreateProviderAndLinkToOrganizationAsync( + ApiApplicationFactory factory, + Guid organizationId, + ProviderType providerType, + ProviderStatusType providerStatus = ProviderStatusType.Created) + { + var providerRepository = factory.GetService(); + var providerOrganizationRepository = factory.GetService(); + + // Create the provider + var provider = await providerRepository.CreateAsync(new Provider + { + Name = $"Test {providerType} Provider", + BusinessName = $"Test {providerType} Provider Business", + BillingEmail = $"provider-{providerType.ToString().ToLower()}@example.com", + Type = providerType, + Status = providerStatus, + Enabled = true + }); + + // Link the provider to the organization + await providerOrganizationRepository.CreateAsync(new ProviderOrganization + { + ProviderId = provider.Id, + OrganizationId = organizationId, + Key = "test-provider-key" + }); + + return provider; + } + + /// + /// Creates a providerUser for a provider. + /// + public static async Task CreateProviderUserAsync( + ApiApplicationFactory factory, + Guid providerId, + string userEmail, + ProviderUserType providerUserType) + { + var userRepository = factory.GetService(); + var user = await userRepository.GetByEmailAsync(userEmail); + if (user is null) + { + throw new Exception("No user found in test setup."); + } + + var providerUserRepository = factory.GetService(); + return await providerUserRepository.CreateAsync(new ProviderUser + { + ProviderId = providerId, + Status = ProviderUserStatusType.Confirmed, + UserId = user.Id, + Key = Guid.NewGuid().ToString(), + Type = providerUserType + }); + } +} diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index f999dd520e..d87f035a13 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -1,5 +1,4 @@ using System.Security.Claims; -using AutoFixture.Xunit2; using Bit.Api.AdminConsole.Controllers; using Bit.Api.Auth.Models.Request.Accounts; using Bit.Api.Models.Request.Organizations; @@ -8,9 +7,6 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Business; -using Bit.Core.AdminConsole.Models.Business.Tokenables; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces; -using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; @@ -20,7 +16,6 @@ using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; -using Bit.Core.Auth.Services; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Services; @@ -31,101 +26,23 @@ using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Test.Billing.Mocks; -using Bit.Core.Tokens; using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using Xunit; -using GlobalSettings = Bit.Core.Settings.GlobalSettings; namespace Bit.Api.Test.AdminConsole.Controllers; -public class OrganizationsControllerTests : IDisposable +[ControllerCustomize(typeof(OrganizationsController))] +[SutProviderCustomize] +public class OrganizationsControllerTests { - private readonly GlobalSettings _globalSettings; - private readonly ICurrentContext _currentContext; - private readonly IOrganizationRepository _organizationRepository; - private readonly IOrganizationService _organizationService; - private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly IPolicyRepository _policyRepository; - private readonly ISsoConfigRepository _ssoConfigRepository; - private readonly ISsoConfigService _ssoConfigService; - private readonly IUserService _userService; - private readonly IGetOrganizationApiKeyQuery _getOrganizationApiKeyQuery; - private readonly IRotateOrganizationApiKeyCommand _rotateOrganizationApiKeyCommand; - private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository; - private readonly ICreateOrganizationApiKeyCommand _createOrganizationApiKeyCommand; - private readonly IFeatureService _featureService; - private readonly IProviderRepository _providerRepository; - private readonly IProviderBillingService _providerBillingService; - private readonly IDataProtectorTokenFactory _orgDeleteTokenDataFactory; - private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; - private readonly ICloudOrganizationSignUpCommand _cloudOrganizationSignUpCommand; - private readonly IOrganizationDeleteCommand _organizationDeleteCommand; - private readonly IPolicyRequirementQuery _policyRequirementQuery; - private readonly IPricingClient _pricingClient; - private readonly IOrganizationUpdateKeysCommand _organizationUpdateKeysCommand; - private readonly OrganizationsController _sut; - - public OrganizationsControllerTests() - { - _currentContext = Substitute.For(); - _globalSettings = Substitute.For(); - _organizationRepository = Substitute.For(); - _organizationService = Substitute.For(); - _organizationUserRepository = Substitute.For(); - _policyRepository = Substitute.For(); - _ssoConfigRepository = Substitute.For(); - _ssoConfigService = Substitute.For(); - _getOrganizationApiKeyQuery = Substitute.For(); - _rotateOrganizationApiKeyCommand = Substitute.For(); - _organizationApiKeyRepository = Substitute.For(); - _userService = Substitute.For(); - _createOrganizationApiKeyCommand = Substitute.For(); - _featureService = Substitute.For(); - _providerRepository = Substitute.For(); - _providerBillingService = Substitute.For(); - _orgDeleteTokenDataFactory = Substitute.For>(); - _removeOrganizationUserCommand = Substitute.For(); - _cloudOrganizationSignUpCommand = Substitute.For(); - _organizationDeleteCommand = Substitute.For(); - _policyRequirementQuery = Substitute.For(); - _pricingClient = Substitute.For(); - _organizationUpdateKeysCommand = Substitute.For(); - - _sut = new OrganizationsController( - _organizationRepository, - _organizationUserRepository, - _policyRepository, - _organizationService, - _userService, - _currentContext, - _ssoConfigRepository, - _ssoConfigService, - _getOrganizationApiKeyQuery, - _rotateOrganizationApiKeyCommand, - _createOrganizationApiKeyCommand, - _organizationApiKeyRepository, - _featureService, - _globalSettings, - _providerRepository, - _providerBillingService, - _orgDeleteTokenDataFactory, - _removeOrganizationUserCommand, - _cloudOrganizationSignUpCommand, - _organizationDeleteCommand, - _policyRequirementQuery, - _pricingClient, - _organizationUpdateKeysCommand); - } - - public void Dispose() - { - _sut?.Dispose(); - } - - [Theory, AutoData] + [Theory, BitAutoData] public async Task OrganizationsController_UserCannotLeaveOrganizationThatProvidesKeyConnector( - Guid orgId, User user) + SutProvider sutProvider, + Guid orgId, + User user) { var ssoConfig = new SsoConfig { @@ -140,21 +57,24 @@ public class OrganizationsControllerTests : IDisposable user.UsesKeyConnector = true; - _currentContext.OrganizationUser(orgId).Returns(true); - _ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig); - _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); - _userService.GetOrganizationsClaimingUserAsync(user.Id).Returns(new List { null }); - var exception = await Assert.ThrowsAsync(() => _sut.Leave(orgId)); + sutProvider.GetDependency().OrganizationUser(orgId).Returns(true); + sutProvider.GetDependency().GetByOrganizationIdAsync(orgId).Returns(ssoConfig); + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).Returns(user); + sutProvider.GetDependency().GetOrganizationsClaimingUserAsync(user.Id).Returns(new List { null }); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.Leave(orgId)); Assert.Contains("Your organization's Single Sign-On settings prevent you from leaving.", exception.Message); - await _removeOrganizationUserCommand.DidNotReceiveWithAnyArgs().UserLeaveAsync(default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().UserLeaveAsync(default, default); } - [Theory, AutoData] + [Theory, BitAutoData] public async Task OrganizationsController_UserCannotLeaveOrganizationThatManagesUser( - Guid orgId, User user) + SutProvider sutProvider, + Guid orgId, + User user) { var ssoConfig = new SsoConfig { @@ -166,27 +86,34 @@ public class OrganizationsControllerTests : IDisposable Enabled = true, OrganizationId = orgId, }; - var foundOrg = new Organization(); - foundOrg.Id = orgId; + var foundOrg = new Organization + { + Id = orgId + }; - _currentContext.OrganizationUser(orgId).Returns(true); - _ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig); - _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); - _userService.GetOrganizationsClaimingUserAsync(user.Id).Returns(new List { { foundOrg } }); - var exception = await Assert.ThrowsAsync(() => _sut.Leave(orgId)); + sutProvider.GetDependency().OrganizationUser(orgId).Returns(true); + sutProvider.GetDependency().GetByOrganizationIdAsync(orgId).Returns(ssoConfig); + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).Returns(user); + sutProvider.GetDependency().GetOrganizationsClaimingUserAsync(user.Id).Returns(new List { foundOrg }); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.Leave(orgId)); Assert.Contains("Claimed user account cannot leave claiming organization. Contact your organization administrator for additional details.", exception.Message); - await _removeOrganizationUserCommand.DidNotReceiveWithAnyArgs().RemoveUserAsync(default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().RemoveUserAsync(default, default); } [Theory] - [InlineAutoData(true, false)] - [InlineAutoData(false, true)] - [InlineAutoData(false, false)] + [BitAutoData(true, false)] + [BitAutoData(false, true)] + [BitAutoData(false, false)] public async Task OrganizationsController_UserCanLeaveOrganizationThatDoesntProvideKeyConnector( - bool keyConnectorEnabled, bool userUsesKeyConnector, Guid orgId, User user) + bool keyConnectorEnabled, + bool userUsesKeyConnector, + SutProvider sutProvider, + Guid orgId, + User user) { var ssoConfig = new SsoConfig { @@ -203,18 +130,19 @@ public class OrganizationsControllerTests : IDisposable user.UsesKeyConnector = userUsesKeyConnector; - _currentContext.OrganizationUser(orgId).Returns(true); - _ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig); - _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); - _userService.GetOrganizationsClaimingUserAsync(user.Id).Returns(new List()); + sutProvider.GetDependency().OrganizationUser(orgId).Returns(true); + sutProvider.GetDependency().GetByOrganizationIdAsync(orgId).Returns(ssoConfig); + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).Returns(user); + sutProvider.GetDependency().GetOrganizationsClaimingUserAsync(user.Id).Returns(new List()); - await _sut.Leave(orgId); + await sutProvider.Sut.Leave(orgId); - await _removeOrganizationUserCommand.Received(1).UserLeaveAsync(orgId, user.Id); + await sutProvider.GetDependency().Received(1).UserLeaveAsync(orgId, user.Id); } - [Theory, AutoData] + [Theory, BitAutoData] public async Task Delete_OrganizationIsConsolidatedBillingClient_ScalesProvidersSeats( + SutProvider sutProvider, Provider provider, Organization organization, User user, @@ -228,87 +156,89 @@ public class OrganizationsControllerTests : IDisposable provider.Type = ProviderType.Msp; provider.Status = ProviderStatusType.Billable; - _currentContext.OrganizationOwner(organizationId).Returns(true); + sutProvider.GetDependency().OrganizationOwner(organizationId).Returns(true); + sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization); + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).Returns(user); + sutProvider.GetDependency().VerifySecretAsync(user, requestModel.Secret).Returns(true); + sutProvider.GetDependency().GetByOrganizationIdAsync(organization.Id).Returns(provider); - _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + await sutProvider.Sut.Delete(organizationId.ToString(), requestModel); - _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); - - _userService.VerifySecretAsync(user, requestModel.Secret).Returns(true); - - _providerRepository.GetByOrganizationIdAsync(organization.Id).Returns(provider); - - await _sut.Delete(organizationId.ToString(), requestModel); - - await _providerBillingService.Received(1) + await sutProvider.GetDependency().Received(1) .ScaleSeats(provider, organization.PlanType, -organization.Seats.Value); - await _organizationDeleteCommand.Received(1).DeleteAsync(organization); + await sutProvider.GetDependency().Received(1).DeleteAsync(organization); } - [Theory, AutoData] + [Theory, BitAutoData] public async Task GetAutoEnrollStatus_WithPolicyRequirementsEnabled_ReturnsOrganizationAutoEnrollStatus_WithResetPasswordEnabledTrue( + SutProvider sutProvider, User user, Organization organization, - OrganizationUser organizationUser - ) + OrganizationUser organizationUser) { - var policyRequirement = new ResetPasswordPolicyRequirement() { AutoEnrollOrganizations = [organization.Id] }; + var policyRequirement = new ResetPasswordPolicyRequirement { AutoEnrollOrganizations = [organization.Id] }; - _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); - _organizationRepository.GetByIdentifierAsync(organization.Id.ToString()).Returns(organization); - _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); - _organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id).Returns(organizationUser); - _policyRequirementQuery.GetAsync(user.Id).Returns(policyRequirement); + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).Returns(user); + sutProvider.GetDependency().GetByIdentifierAsync(organization.Id.ToString()).Returns(organization); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); + sutProvider.GetDependency().GetByOrganizationAsync(organization.Id, user.Id).Returns(organizationUser); + sutProvider.GetDependency().GetAsync(user.Id).Returns(policyRequirement); - var result = await _sut.GetAutoEnrollStatus(organization.Id.ToString()); + var result = await sutProvider.Sut.GetAutoEnrollStatus(organization.Id.ToString()); - await _userService.Received(1).GetUserByPrincipalAsync(Arg.Any()); - await _organizationRepository.Received(1).GetByIdentifierAsync(organization.Id.ToString()); - await _policyRequirementQuery.Received(1).GetAsync(user.Id); + await sutProvider.GetDependency().Received(1).GetUserByPrincipalAsync(Arg.Any()); + await sutProvider.GetDependency().Received(1).GetByIdentifierAsync(organization.Id.ToString()); + await sutProvider.GetDependency().Received(1).GetAsync(user.Id); Assert.True(result.ResetPasswordEnabled); Assert.Equal(result.Id, organization.Id); } - [Theory, AutoData] + [Theory, BitAutoData] public async Task GetAutoEnrollStatus_WithPolicyRequirementsDisabled_ReturnsOrganizationAutoEnrollStatus_WithResetPasswordEnabledTrue( - User user, - Organization organization, - OrganizationUser organizationUser -) + SutProvider sutProvider, + User user, + Organization organization, + OrganizationUser organizationUser) { + var policy = new Policy + { + Type = PolicyType.ResetPassword, + Enabled = true, + Data = "{\"AutoEnrollEnabled\": true}", + OrganizationId = organization.Id + }; - var policy = new Policy() { Type = PolicyType.ResetPassword, Enabled = true, Data = "{\"AutoEnrollEnabled\": true}", OrganizationId = organization.Id }; + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).Returns(user); + sutProvider.GetDependency().GetByIdentifierAsync(organization.Id.ToString()).Returns(organization); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(false); + sutProvider.GetDependency().GetByOrganizationAsync(organization.Id, user.Id).Returns(organizationUser); + sutProvider.GetDependency().GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword).Returns(policy); - _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); - _organizationRepository.GetByIdentifierAsync(organization.Id.ToString()).Returns(organization); - _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(false); - _organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id).Returns(organizationUser); - _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword).Returns(policy); + var result = await sutProvider.Sut.GetAutoEnrollStatus(organization.Id.ToString()); - var result = await _sut.GetAutoEnrollStatus(organization.Id.ToString()); - - await _userService.Received(1).GetUserByPrincipalAsync(Arg.Any()); - await _organizationRepository.Received(1).GetByIdentifierAsync(organization.Id.ToString()); - await _policyRequirementQuery.Received(0).GetAsync(user.Id); - await _policyRepository.Received(1).GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword); + await sutProvider.GetDependency().Received(1).GetUserByPrincipalAsync(Arg.Any()); + await sutProvider.GetDependency().Received(1).GetByIdentifierAsync(organization.Id.ToString()); + await sutProvider.GetDependency().Received(0).GetAsync(user.Id); + await sutProvider.GetDependency().Received(1).GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword); Assert.True(result.ResetPasswordEnabled); } - [Theory, AutoData] + [Theory, BitAutoData] public async Task PutCollectionManagement_ValidRequest_Success( + SutProvider sutProvider, Organization organization, OrganizationCollectionManagementUpdateRequestModel model) { // Arrange - _currentContext.OrganizationOwner(organization.Id).Returns(true); + sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(true); var plan = MockPlans.Get(PlanType.EnterpriseAnnually); - _pricingClient.GetPlan(Arg.Any()).Returns(plan); + sutProvider.GetDependency().GetPlan(Arg.Any()).Returns(plan); - _organizationService + sutProvider.GetDependency() .UpdateCollectionManagementSettingsAsync( organization.Id, Arg.Is(s => @@ -319,10 +249,10 @@ public class OrganizationsControllerTests : IDisposable .Returns(organization); // Act - await _sut.PutCollectionManagement(organization.Id, model); + await sutProvider.Sut.PutCollectionManagement(organization.Id, model); // Assert - await _organizationService + await sutProvider.GetDependency() .Received(1) .UpdateCollectionManagementSettingsAsync( organization.Id, diff --git a/test/Billing.Test/Jobs/ReconcileAdditionalStorageJobTests.cs b/test/Billing.Test/Jobs/ReconcileAdditionalStorageJobTests.cs new file mode 100644 index 0000000000..deb164f232 --- /dev/null +++ b/test/Billing.Test/Jobs/ReconcileAdditionalStorageJobTests.cs @@ -0,0 +1,641 @@ +using Bit.Billing.Jobs; +using Bit.Billing.Services; +using Bit.Core; +using Bit.Core.Billing.Constants; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Quartz; +using Stripe; +using Xunit; + +namespace Bit.Billing.Test.Jobs; + +public class ReconcileAdditionalStorageJobTests +{ + private readonly IStripeFacade _stripeFacade; + private readonly ILogger _logger; + private readonly IFeatureService _featureService; + private readonly ReconcileAdditionalStorageJob _sut; + + public ReconcileAdditionalStorageJobTests() + { + _stripeFacade = Substitute.For(); + _logger = Substitute.For>(); + _featureService = Substitute.For(); + _sut = new ReconcileAdditionalStorageJob(_stripeFacade, _logger, _featureService); + } + + #region Feature Flag Tests + + [Fact] + public async Task Execute_FeatureFlagDisabled_SkipsProcessing() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob) + .Returns(false); + + // Act + await _sut.Execute(context); + + // Assert + _stripeFacade.DidNotReceiveWithAnyArgs().ListSubscriptionsAutoPagingAsync(); + } + + [Fact] + public async Task Execute_FeatureFlagEnabled_ProcessesSubscriptions() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob) + .Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode) + .Returns(false); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Empty()); + + // Act + await _sut.Execute(context); + + // Assert + _stripeFacade.Received(3).ListSubscriptionsAutoPagingAsync( + Arg.Is(o => o.Status == "active")); + } + + #endregion + + #region Dry Run Mode Tests + + [Fact] + public async Task Execute_DryRunMode_DoesNotUpdateSubscriptions() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(false); // Dry run ON + + var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10); + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.DidNotReceiveWithAnyArgs().UpdateSubscription(null!); + } + + [Fact] + public async Task Execute_DryRunModeDisabled_UpdatesSubscriptions() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); // Dry run OFF + + var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10); + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Returns(subscription); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription( + "sub_123", + Arg.Is(o => o.Items.Count == 1)); + } + + #endregion + + #region Price ID Processing Tests + + [Fact] + public async Task Execute_ProcessesAllThreePriceIds() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(false); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Empty()); + + // Act + await _sut.Execute(context); + + // Assert + _stripeFacade.Received(1).ListSubscriptionsAutoPagingAsync( + Arg.Is(o => o.Price == "storage-gb-monthly")); + _stripeFacade.Received(1).ListSubscriptionsAutoPagingAsync( + Arg.Is(o => o.Price == "storage-gb-annually")); + _stripeFacade.Received(1).ListSubscriptionsAutoPagingAsync( + Arg.Is(o => o.Price == "personal-storage-gb-annually")); + } + + #endregion + + #region Already Processed Tests + + [Fact] + public async Task Execute_SubscriptionAlreadyProcessed_SkipsUpdate() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + var metadata = new Dictionary + { + [StripeConstants.MetadataKeys.StorageReconciled2025] = DateTime.UtcNow.ToString("o") + }; + var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, metadata: metadata); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.DidNotReceiveWithAnyArgs().UpdateSubscription(null!); + } + + [Fact] + public async Task Execute_SubscriptionWithInvalidProcessedDate_ProcessesSubscription() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + var metadata = new Dictionary + { + [StripeConstants.MetadataKeys.StorageReconciled2025] = "invalid-date" + }; + var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, metadata: metadata); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Returns(subscription); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription("sub_123", Arg.Any()); + } + + [Fact] + public async Task Execute_SubscriptionWithoutMetadata_ProcessesSubscription() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, metadata: null); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Returns(subscription); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription("sub_123", Arg.Any()); + } + + #endregion + + #region Quantity Reduction Logic Tests + + [Fact] + public async Task Execute_QuantityGreaterThan4_ReducesBy4() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Returns(subscription); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription( + "sub_123", + Arg.Is(o => + o.Items.Count == 1 && + o.Items[0].Quantity == 6 && + o.Items[0].Deleted != true)); + } + + [Fact] + public async Task Execute_QuantityEquals4_DeletesItem() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 4); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Returns(subscription); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription( + "sub_123", + Arg.Is(o => + o.Items.Count == 1 && + o.Items[0].Deleted == true)); + } + + [Fact] + public async Task Execute_QuantityLessThan4_DeletesItem() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 2); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Returns(subscription); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription( + "sub_123", + Arg.Is(o => + o.Items.Count == 1 && + o.Items[0].Deleted == true)); + } + + #endregion + + #region Update Options Tests + + [Fact] + public async Task Execute_UpdateOptions_SetsProrationBehaviorToCreateProrations() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Returns(subscription); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription( + "sub_123", + Arg.Is(o => o.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations)); + } + + [Fact] + public async Task Execute_UpdateOptions_SetsReconciledMetadata() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Returns(subscription); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription( + "sub_123", + Arg.Is(o => + o.Metadata.ContainsKey(StripeConstants.MetadataKeys.StorageReconciled2025) && + !string.IsNullOrEmpty(o.Metadata[StripeConstants.MetadataKeys.StorageReconciled2025]))); + } + + #endregion + + #region Subscription Filtering Tests + + [Fact] + public async Task Execute_SubscriptionWithNoItems_SkipsUpdate() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + var subscription = new Subscription + { + Id = "sub_123", + Items = null + }; + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.DidNotReceiveWithAnyArgs().UpdateSubscription(null!); + } + + [Fact] + public async Task Execute_SubscriptionWithDifferentPriceId_SkipsUpdate() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + var subscription = CreateSubscription("sub_123", "different-price-id", quantity: 10); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.DidNotReceiveWithAnyArgs().UpdateSubscription(null!); + } + + [Fact] + public async Task Execute_NullSubscription_SkipsProcessing() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(null!)); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.DidNotReceiveWithAnyArgs().UpdateSubscription(null!); + } + + #endregion + + #region Multiple Subscriptions Tests + + [Fact] + public async Task Execute_MultipleSubscriptions_ProcessesAll() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + var subscription1 = CreateSubscription("sub_1", "storage-gb-monthly", quantity: 10); + var subscription2 = CreateSubscription("sub_2", "storage-gb-monthly", quantity: 5); + var subscription3 = CreateSubscription("sub_3", "storage-gb-monthly", quantity: 3); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription1, subscription2, subscription3)); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Returns(callInfo => callInfo.Arg() switch + { + "sub_1" => subscription1, + "sub_2" => subscription2, + "sub_3" => subscription3, + _ => null + }); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription("sub_1", Arg.Any()); + await _stripeFacade.Received(1).UpdateSubscription("sub_2", Arg.Any()); + await _stripeFacade.Received(1).UpdateSubscription("sub_3", Arg.Any()); + } + + [Fact] + public async Task Execute_MixedSubscriptionsWithProcessed_OnlyProcessesUnprocessed() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + var processedMetadata = new Dictionary + { + [StripeConstants.MetadataKeys.StorageReconciled2025] = DateTime.UtcNow.ToString("o") + }; + + var subscription1 = CreateSubscription("sub_1", "storage-gb-monthly", quantity: 10); + var subscription2 = CreateSubscription("sub_2", "storage-gb-monthly", quantity: 5, metadata: processedMetadata); + var subscription3 = CreateSubscription("sub_3", "storage-gb-monthly", quantity: 3); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription1, subscription2, subscription3)); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Returns(callInfo => callInfo.Arg() switch + { + "sub_1" => subscription1, + "sub_3" => subscription3, + _ => null + }); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription("sub_1", Arg.Any()); + await _stripeFacade.DidNotReceive().UpdateSubscription("sub_2", Arg.Any()); + await _stripeFacade.Received(1).UpdateSubscription("sub_3", Arg.Any()); + } + + #endregion + + #region Error Handling Tests + + [Fact] + public async Task Execute_UpdateFails_ContinuesProcessingOthers() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + var subscription1 = CreateSubscription("sub_1", "storage-gb-monthly", quantity: 10); + var subscription2 = CreateSubscription("sub_2", "storage-gb-monthly", quantity: 5); + var subscription3 = CreateSubscription("sub_3", "storage-gb-monthly", quantity: 3); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription1, subscription2, subscription3)); + + _stripeFacade.UpdateSubscription("sub_1", Arg.Any()) + .Returns(subscription1); + _stripeFacade.UpdateSubscription("sub_2", Arg.Any()) + .Throws(new Exception("Stripe API error")); + _stripeFacade.UpdateSubscription("sub_3", Arg.Any()) + .Returns(subscription3); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription("sub_1", Arg.Any()); + await _stripeFacade.Received(1).UpdateSubscription("sub_2", Arg.Any()); + await _stripeFacade.Received(1).UpdateSubscription("sub_3", Arg.Any()); + } + + [Fact] + public async Task Execute_UpdateFails_LogsError() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Throws(new Exception("Stripe API error")); + + // Act + await _sut.Execute(context); + + // Assert + _logger.Received().Log( + LogLevel.Error, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>()); + } + + #endregion + + #region Cancellation Tests + + [Fact] + public async Task Execute_CancellationRequested_LogsWarningAndExits() + { + // Arrange + var cts = new CancellationTokenSource(); + cts.Cancel(); // Cancel immediately + var context = CreateJobExecutionContext(cts.Token); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + var subscription1 = CreateSubscription("sub_1", "storage-gb-monthly", quantity: 10); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription1)); + + // Act + await _sut.Execute(context); + + // Assert - Should not process any subscriptions due to immediate cancellation + await _stripeFacade.DidNotReceiveWithAnyArgs().UpdateSubscription(null); + _logger.Received().Log( + LogLevel.Warning, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>()); + } + + #endregion + + #region Helper Methods + + private static IJobExecutionContext CreateJobExecutionContext(CancellationToken cancellationToken = default) + { + var context = Substitute.For(); + context.CancellationToken.Returns(cancellationToken); + return context; + } + + private static Subscription CreateSubscription( + string id, + string priceId, + long? quantity = null, + Dictionary? metadata = null) + { + var price = new Price { Id = priceId }; + var item = new SubscriptionItem + { + Id = $"si_{id}", + Price = price, + Quantity = quantity ?? 0 + }; + + return new Subscription + { + Id = id, + Metadata = metadata, + Items = new StripeList + { + Data = new List { item } + } + }; + } + + #endregion +} + +internal static class AsyncEnumerable +{ + public static async IAsyncEnumerable Create(params T[] items) + { + foreach (var item in items) + { + yield return item; + } + await Task.CompletedTask; + } + + public static async IAsyncEnumerable Empty() + { + await Task.CompletedTask; + yield break; + } +} diff --git a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs index 89c926ee31..f1d8c4ba2e 100644 --- a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs +++ b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs @@ -1,4 +1,5 @@ -using Bit.Billing.Services; +using System.Globalization; +using Bit.Billing.Services; using Bit.Billing.Services.Implementations; using Bit.Core; using Bit.Core.AdminConsole.Entities; @@ -10,7 +11,7 @@ using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing.Premium; using Bit.Core.Entities; -using Bit.Core.Models.Mail.UpdatedInvoiceIncoming; +using Bit.Core.Models.Mail.Billing.Renewal.Families2020Renewal; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Platform.Mail.Mailer; using Bit.Core.Repositories; @@ -117,7 +118,7 @@ public class UpcomingInvoiceHandlerTests NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; var subscription = new Subscription @@ -126,10 +127,7 @@ public class UpcomingInvoiceHandlerTests CustomerId = customerId, Items = new StripeList { - Data = new List - { - new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } } - } + Data = [new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } }] }, AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, Customer = new Customer { Id = customerId }, @@ -199,7 +197,7 @@ public class UpcomingInvoiceHandlerTests NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; var subscription = new Subscription @@ -208,10 +206,7 @@ public class UpcomingInvoiceHandlerTests CustomerId = customerId, Items = new StripeList { - Data = new List - { - new() { Id = priceSubscriptionId, Price = new Price { Id = Prices.PremiumAnnually } } - } + Data = [new() { Id = priceSubscriptionId, Price = new Price { Id = Prices.PremiumAnnually } }] }, AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, Customer = new Customer @@ -233,7 +228,7 @@ public class UpcomingInvoiceHandlerTests var customer = new Customer { Id = customerId, - Subscriptions = new StripeList { Data = new List { subscription } } + Subscriptions = new StripeList { Data = [subscription] } }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); @@ -272,11 +267,12 @@ public class UpcomingInvoiceHandlerTests o.Discounts[0].Coupon == CouponIDs.Milestone2SubscriptionDiscount && o.ProrationBehavior == "none")); - // Verify the updated invoice email was sent + // Verify the updated invoice email was sent with correct price await _mailer.Received(1).SendEmail( - Arg.Is(email => + Arg.Is(email => email.ToEmails.Contains("user@example.com") && - email.Subject == "Your Subscription Will Renew Soon")); + email.Subject == "Your Bitwarden Families renewal is updating" && + email.View.MonthlyRenewalPrice == (plan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")))); } [Fact] @@ -291,7 +287,7 @@ public class UpcomingInvoiceHandlerTests NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; var subscription = new Subscription @@ -307,7 +303,7 @@ public class UpcomingInvoiceHandlerTests var customer = new Customer { Id = "cus_123", - Subscriptions = new StripeList { Data = new List { subscription } }, + Subscriptions = new StripeList { Data = [subscription] }, Address = new Address { Country = "US" } }; var organization = new Organization @@ -375,7 +371,7 @@ public class UpcomingInvoiceHandlerTests NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; var subscription = new Subscription @@ -395,7 +391,7 @@ public class UpcomingInvoiceHandlerTests var customer = new Customer { Id = "cus_123", - Subscriptions = new StripeList { Data = new List { subscription } }, + Subscriptions = new StripeList { Data = [subscription] }, Address = new Address { Country = "US" } }; var organization = new Organization @@ -469,7 +465,7 @@ public class UpcomingInvoiceHandlerTests NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; var subscription = new Subscription @@ -489,7 +485,7 @@ public class UpcomingInvoiceHandlerTests var customer = new Customer { Id = "cus_123", - Subscriptions = new StripeList { Data = new List { subscription } }, + Subscriptions = new StripeList { Data = [subscription] }, Address = new Address { Country = "US" } }; var organization = new Organization @@ -560,7 +556,7 @@ public class UpcomingInvoiceHandlerTests NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; var subscription = new Subscription @@ -576,7 +572,7 @@ public class UpcomingInvoiceHandlerTests var customer = new Customer { Id = "cus_123", - Subscriptions = new StripeList { Data = new List { subscription } }, + Subscriptions = new StripeList { Data = [subscription] }, Address = new Address { Country = "UK" }, TaxExempt = TaxExempt.None }; @@ -622,9 +618,8 @@ public class UpcomingInvoiceHandlerTests } [Fact] - public async Task HandleAsync_WhenUpdateSubscriptionItemPriceIdFails_LogsErrorAndSendsEmail() + public async Task HandleAsync_WhenUpdateSubscriptionItemPriceIdFails_LogsErrorAndSendsTraditionalEmail() { - // Arrange // Arrange var parsedEvent = new Event { Id = "evt_123" }; var customerId = "cus_123"; @@ -637,7 +632,7 @@ public class UpcomingInvoiceHandlerTests NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; var subscription = new Subscription @@ -646,10 +641,7 @@ public class UpcomingInvoiceHandlerTests CustomerId = customerId, Items = new StripeList { - Data = new List - { - new() { Id = priceSubscriptionId, Price = new Price { Id = Prices.PremiumAnnually } } - } + Data = [new() { Id = priceSubscriptionId, Price = new Price { Id = Prices.PremiumAnnually } }] }, AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, Customer = new Customer @@ -671,7 +663,7 @@ public class UpcomingInvoiceHandlerTests var customer = new Customer { Id = customerId, - Subscriptions = new StripeList { Data = new List { subscription } } + Subscriptions = new StripeList { Data = [subscription] } }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); @@ -708,11 +700,16 @@ public class UpcomingInvoiceHandlerTests Arg.Any(), Arg.Any>()); - // Verify that email was still sent despite the exception - await _mailer.Received(1).SendEmail( - Arg.Is(email => - email.ToEmails.Contains("user@example.com") && - email.Subject == "Your Subscription Will Renew Soon")); + // Verify that traditional email was sent when update fails + await _mailService.Received(1).SendInvoiceUpcoming( + Arg.Is>(emails => emails.Contains("user@example.com")), + Arg.Is(amount => amount == invoice.AmountDue / 100M), + Arg.Is(dueDate => dueDate == invoice.NextPaymentAttempt.Value), + Arg.Is>(items => items.Count == invoice.Lines.Data.Count), + Arg.Is(b => b == true)); + + // Verify renewal email was NOT sent + await _mailer.DidNotReceive().SendEmail(Arg.Any()); } [Fact] @@ -727,7 +724,7 @@ public class UpcomingInvoiceHandlerTests NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; var subscription = new Subscription @@ -737,12 +734,12 @@ public class UpcomingInvoiceHandlerTests Items = new StripeList(), AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, Customer = new Customer { Id = "cus_123" }, - Metadata = new Dictionary(), + Metadata = new Dictionary() }; var customer = new Customer { Id = "cus_123", - Subscriptions = new StripeList { Data = new List { subscription } } + Subscriptions = new StripeList { Data = [subscription] } }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); @@ -784,7 +781,7 @@ public class UpcomingInvoiceHandlerTests NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Free Item" } } + Data = [new() { Description = "Free Item" }] } }; var subscription = new Subscription @@ -800,7 +797,7 @@ public class UpcomingInvoiceHandlerTests var customer = new Customer { Id = "cus_123", - Subscriptions = new StripeList { Data = new List { subscription } } + Subscriptions = new StripeList { Data = [subscription] } }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); @@ -841,7 +838,7 @@ public class UpcomingInvoiceHandlerTests NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; var subscription = new Subscription @@ -856,7 +853,7 @@ public class UpcomingInvoiceHandlerTests var customer = new Customer { Id = "cus_123", - Subscriptions = new StripeList { Data = new List { subscription } } + Subscriptions = new StripeList { Data = [subscription] } }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); @@ -885,7 +882,7 @@ public class UpcomingInvoiceHandlerTests Arg.Any>(), Arg.Any()); - await _mailer.DidNotReceive().SendEmail(Arg.Any()); + await _mailer.DidNotReceive().SendEmail(Arg.Any()); } [Fact] @@ -900,7 +897,7 @@ public class UpcomingInvoiceHandlerTests NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; var subscription = new Subscription @@ -915,7 +912,7 @@ public class UpcomingInvoiceHandlerTests var customer = new Customer { Id = "cus_123", - Subscriptions = new StripeList { Data = new List { subscription } } + Subscriptions = new StripeList { Data = [subscription] } }; _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); @@ -964,7 +961,7 @@ public class UpcomingInvoiceHandlerTests NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; @@ -977,8 +974,8 @@ public class UpcomingInvoiceHandlerTests CustomerId = customerId, Items = new StripeList { - Data = new List - { + Data = + [ new() { Id = passwordManagerItemId, @@ -989,7 +986,7 @@ public class UpcomingInvoiceHandlerTests Id = premiumAccessItemId, Price = new Price { Id = families2019Plan.PasswordManager.StripePremiumAccessPlanId } } - } + ] }, AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, Metadata = new Dictionary() @@ -998,7 +995,7 @@ public class UpcomingInvoiceHandlerTests var customer = new Customer { Id = customerId, - Subscriptions = new StripeList { Data = new List { subscription } }, + Subscriptions = new StripeList { Data = [subscription] }, Address = new Address { Country = "US" } }; @@ -1045,9 +1042,10 @@ public class UpcomingInvoiceHandlerTests org.Seats == familiesPlan.PasswordManager.BaseSeats)); await _mailer.Received(1).SendEmail( - Arg.Is(email => + Arg.Is(email => email.ToEmails.Contains("org@example.com") && - email.Subject == "Your Subscription Will Renew Soon")); + email.Subject == "Your Bitwarden Families renewal is updating" && + email.View.MonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")))); } [Fact] @@ -1066,7 +1064,7 @@ public class UpcomingInvoiceHandlerTests NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; @@ -1079,14 +1077,14 @@ public class UpcomingInvoiceHandlerTests CustomerId = customerId, Items = new StripeList { - Data = new List - { + Data = + [ new() { Id = passwordManagerItemId, Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } } - } + ] }, AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, Metadata = new Dictionary() @@ -1095,7 +1093,7 @@ public class UpcomingInvoiceHandlerTests var customer = new Customer { Id = customerId, - Subscriptions = new StripeList { Data = new List { subscription } }, + Subscriptions = new StripeList { Data = [subscription] }, Address = new Address { Country = "US" } }; @@ -1156,7 +1154,7 @@ public class UpcomingInvoiceHandlerTests NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; @@ -1168,14 +1166,14 @@ public class UpcomingInvoiceHandlerTests CustomerId = customerId, Items = new StripeList { - Data = new List - { + Data = + [ new() { Id = passwordManagerItemId, Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } } - } + ] }, AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, Metadata = new Dictionary() @@ -1184,7 +1182,7 @@ public class UpcomingInvoiceHandlerTests var customer = new Customer { Id = customerId, - Subscriptions = new StripeList { Data = new List { subscription } }, + Subscriptions = new StripeList { Data = [subscription] }, Address = new Address { Country = "US" } }; @@ -1232,7 +1230,7 @@ public class UpcomingInvoiceHandlerTests NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; @@ -1244,14 +1242,10 @@ public class UpcomingInvoiceHandlerTests CustomerId = customerId, Items = new StripeList { - Data = new List - { - new() - { - Id = "si_pm_123", - Price = new Price { Id = familiesPlan.PasswordManager.StripePlanId } - } - } + Data = + [ + new() { Id = "si_pm_123", Price = new Price { Id = familiesPlan.PasswordManager.StripePlanId } } + ] }, AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, Metadata = new Dictionary() @@ -1260,7 +1254,7 @@ public class UpcomingInvoiceHandlerTests var customer = new Customer { Id = customerId, - Subscriptions = new StripeList { Data = new List { subscription } }, + Subscriptions = new StripeList { Data = [subscription] }, Address = new Address { Country = "US" } }; @@ -1307,7 +1301,7 @@ public class UpcomingInvoiceHandlerTests NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; @@ -1319,14 +1313,10 @@ public class UpcomingInvoiceHandlerTests CustomerId = customerId, Items = new StripeList { - Data = new List - { - new() - { - Id = "si_different_item", - Price = new Price { Id = "different-price-id" } - } - } + Data = + [ + new() { Id = "si_different_item", Price = new Price { Id = "different-price-id" } } + ] }, AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, Metadata = new Dictionary() @@ -1335,7 +1325,7 @@ public class UpcomingInvoiceHandlerTests var customer = new Customer { Id = customerId, - Subscriptions = new StripeList { Data = new List { subscription } }, + Subscriptions = new StripeList { Data = [subscription] }, Address = new Address { Country = "US" } }; @@ -1378,7 +1368,7 @@ public class UpcomingInvoiceHandlerTests } [Fact] - public async Task HandleAsync_WhenMilestone3Enabled_AndUpdateFails_LogsError() + public async Task HandleAsync_WhenMilestone3Enabled_AndUpdateFails_LogsErrorAndSendsTraditionalEmail() { // Arrange var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; @@ -1393,7 +1383,7 @@ public class UpcomingInvoiceHandlerTests NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; @@ -1406,14 +1396,14 @@ public class UpcomingInvoiceHandlerTests CustomerId = customerId, Items = new StripeList { - Data = new List - { + Data = + [ new() { Id = passwordManagerItemId, Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } } - } + ] }, AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, Metadata = new Dictionary() @@ -1422,7 +1412,7 @@ public class UpcomingInvoiceHandlerTests var customer = new Customer { Id = customerId, - Subscriptions = new StripeList { Data = new List { subscription } }, + Subscriptions = new StripeList { Data = [subscription] }, Address = new Address { Country = "US" } }; @@ -1463,11 +1453,16 @@ public class UpcomingInvoiceHandlerTests Arg.Any(), Arg.Any>()); - // Should still attempt to send email despite the failure - await _mailer.Received(1).SendEmail( - Arg.Is(email => - email.ToEmails.Contains("org@example.com") && - email.Subject == "Your Subscription Will Renew Soon")); + // Should send traditional email when update fails + await _mailService.Received(1).SendInvoiceUpcoming( + Arg.Is>(emails => emails.Contains("org@example.com")), + Arg.Is(amount => amount == invoice.AmountDue / 100M), + Arg.Is(dueDate => dueDate == invoice.NextPaymentAttempt.Value), + Arg.Is>(items => items.Count == invoice.Lines.Data.Count), + Arg.Is(b => b == true)); + + // Verify renewal email was NOT sent + await _mailer.DidNotReceive().SendEmail(Arg.Any()); } [Fact] @@ -1487,7 +1482,7 @@ public class UpcomingInvoiceHandlerTests NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; @@ -1500,20 +1495,21 @@ public class UpcomingInvoiceHandlerTests CustomerId = customerId, Items = new StripeList { - Data = new List - { + Data = + [ new() { Id = passwordManagerItemId, Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } }, + new() { Id = seatAddOnItemId, Price = new Price { Id = "personal-org-seat-annually" }, Quantity = 3 } - } + ] }, AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, Metadata = new Dictionary() @@ -1522,7 +1518,7 @@ public class UpcomingInvoiceHandlerTests var customer = new Customer { Id = customerId, - Subscriptions = new StripeList { Data = new List { subscription } }, + Subscriptions = new StripeList { Data = [subscription] }, Address = new Address { Country = "US" } }; @@ -1569,9 +1565,10 @@ public class UpcomingInvoiceHandlerTests org.Seats == familiesPlan.PasswordManager.BaseSeats)); await _mailer.Received(1).SendEmail( - Arg.Is(email => + Arg.Is(email => email.ToEmails.Contains("org@example.com") && - email.Subject == "Your Subscription Will Renew Soon")); + email.Subject == "Your Bitwarden Families renewal is updating" && + email.View.MonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")))); } [Fact] @@ -1591,7 +1588,7 @@ public class UpcomingInvoiceHandlerTests NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; @@ -1604,20 +1601,21 @@ public class UpcomingInvoiceHandlerTests CustomerId = customerId, Items = new StripeList { - Data = new List - { + Data = + [ new() { Id = passwordManagerItemId, Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } }, + new() { Id = seatAddOnItemId, Price = new Price { Id = "personal-org-seat-annually" }, Quantity = 1 } - } + ] }, AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, Metadata = new Dictionary() @@ -1626,7 +1624,7 @@ public class UpcomingInvoiceHandlerTests var customer = new Customer { Id = customerId, - Subscriptions = new StripeList { Data = new List { subscription } }, + Subscriptions = new StripeList { Data = [subscription] }, Address = new Address { Country = "US" } }; @@ -1673,9 +1671,10 @@ public class UpcomingInvoiceHandlerTests org.Seats == familiesPlan.PasswordManager.BaseSeats)); await _mailer.Received(1).SendEmail( - Arg.Is(email => + Arg.Is(email => email.ToEmails.Contains("org@example.com") && - email.Subject == "Your Subscription Will Renew Soon")); + email.Subject == "Your Bitwarden Families renewal is updating" && + email.View.MonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")))); } [Fact] @@ -1696,7 +1695,7 @@ public class UpcomingInvoiceHandlerTests NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; @@ -1709,25 +1708,27 @@ public class UpcomingInvoiceHandlerTests CustomerId = customerId, Items = new StripeList { - Data = new List - { + Data = + [ new() { Id = passwordManagerItemId, Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } }, + new() { Id = premiumAccessItemId, Price = new Price { Id = families2019Plan.PasswordManager.StripePremiumAccessPlanId } }, + new() { Id = seatAddOnItemId, Price = new Price { Id = "personal-org-seat-annually" }, Quantity = 2 } - } + ] }, AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, Metadata = new Dictionary() @@ -1736,7 +1737,7 @@ public class UpcomingInvoiceHandlerTests var customer = new Customer { Id = customerId, - Subscriptions = new StripeList { Data = new List { subscription } }, + Subscriptions = new StripeList { Data = [subscription] }, Address = new Address { Country = "US" } }; @@ -1785,9 +1786,10 @@ public class UpcomingInvoiceHandlerTests org.Seats == familiesPlan.PasswordManager.BaseSeats)); await _mailer.Received(1).SendEmail( - Arg.Is(email => + Arg.Is(email => email.ToEmails.Contains("org@example.com") && - email.Subject == "Your Subscription Will Renew Soon")); + email.Subject == "Your Bitwarden Families renewal is updating" && + email.View.MonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")))); } [Fact] @@ -1806,7 +1808,7 @@ public class UpcomingInvoiceHandlerTests NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; @@ -1819,14 +1821,14 @@ public class UpcomingInvoiceHandlerTests CustomerId = customerId, Items = new StripeList { - Data = new List - { + Data = + [ new() { Id = passwordManagerItemId, Price = new Price { Id = families2025Plan.PasswordManager.StripePlanId } } - } + ] }, AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, Metadata = new Dictionary() @@ -1835,7 +1837,7 @@ public class UpcomingInvoiceHandlerTests var customer = new Customer { Id = customerId, - Subscriptions = new StripeList { Data = new List { subscription } }, + Subscriptions = new StripeList { Data = [subscription] }, Address = new Address { Country = "US" } }; @@ -1895,7 +1897,7 @@ public class UpcomingInvoiceHandlerTests NextPaymentAttempt = DateTime.UtcNow.AddDays(7), Lines = new StripeList { - Data = new List { new() { Description = "Test Item" } } + Data = [new() { Description = "Test Item" }] } }; @@ -1907,14 +1909,14 @@ public class UpcomingInvoiceHandlerTests CustomerId = customerId, Items = new StripeList { - Data = new List - { + Data = + [ new() { Id = passwordManagerItemId, Price = new Price { Id = families2025Plan.PasswordManager.StripePlanId } } - } + ] }, AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, Metadata = new Dictionary() @@ -1923,7 +1925,7 @@ public class UpcomingInvoiceHandlerTests var customer = new Customer { Id = customerId, - Subscriptions = new StripeList { Data = new List { subscription } }, + Subscriptions = new StripeList { Data = [subscription] }, Address = new Address { Country = "US" } }; diff --git a/test/Core.IntegrationTest/Core.IntegrationTest.csproj b/test/Core.IntegrationTest/Core.IntegrationTest.csproj index 21b746c2fb..d964452f4c 100644 --- a/test/Core.IntegrationTest/Core.IntegrationTest.csproj +++ b/test/Core.IntegrationTest/Core.IntegrationTest.csproj @@ -14,8 +14,8 @@ - - + + diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateCommandTests.cs new file mode 100644 index 0000000000..3a60a6ffd2 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateCommandTests.cs @@ -0,0 +1,414 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update; +using Bit.Core.Billing.Organizations.Services; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Organizations; + +[SutProviderCustomize] +public class OrganizationUpdateCommandTests +{ + [Theory, BitAutoData] + public async Task UpdateAsync_WhenValidOrganization_UpdatesOrganization( + Guid organizationId, + string name, + string billingEmail, + Organization organization, + SutProvider sutProvider) + { + // Arrange + var organizationRepository = sutProvider.GetDependency(); + var organizationService = sutProvider.GetDependency(); + var organizationBillingService = sutProvider.GetDependency(); + + organization.Id = organizationId; + organization.GatewayCustomerId = null; // No Stripe customer, so no billing update + + organizationRepository + .GetByIdAsync(organizationId) + .Returns(organization); + + var request = new OrganizationUpdateRequest + { + OrganizationId = organizationId, + Name = name, + BillingEmail = billingEmail + }; + + // Act + var result = await sutProvider.Sut.UpdateAsync(request); + + // Assert + Assert.NotNull(result); + Assert.Equal(organizationId, result.Id); + Assert.Equal(name, result.Name); + Assert.Equal(billingEmail.ToLowerInvariant().Trim(), result.BillingEmail); + + await organizationRepository + .Received(1) + .GetByIdAsync(Arg.Is(id => id == organizationId)); + await organizationService + .Received(1) + .ReplaceAndUpdateCacheAsync( + result, + EventType.Organization_Updated); + await organizationBillingService + .DidNotReceiveWithAnyArgs() + .UpdateOrganizationNameAndEmail(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_WhenOrganizationNotFound_ThrowsNotFoundException( + Guid organizationId, + string name, + string billingEmail, + SutProvider sutProvider) + { + // Arrange + var organizationRepository = sutProvider.GetDependency(); + + organizationRepository + .GetByIdAsync(organizationId) + .Returns((Organization)null); + + var request = new OrganizationUpdateRequest + { + OrganizationId = organizationId, + Name = name, + BillingEmail = billingEmail + }; + + // Act/Assert + await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateAsync(request)); + } + + [Theory] + [BitAutoData("")] + [BitAutoData((string)null)] + public async Task UpdateAsync_WhenGatewayCustomerIdIsNullOrEmpty_SkipsBillingUpdate( + string gatewayCustomerId, + Guid organizationId, + Organization organization, + SutProvider sutProvider) + { + // Arrange + var organizationRepository = sutProvider.GetDependency(); + var organizationService = sutProvider.GetDependency(); + var organizationBillingService = sutProvider.GetDependency(); + + organization.Id = organizationId; + organization.Name = "Old Name"; + organization.GatewayCustomerId = gatewayCustomerId; + + organizationRepository + .GetByIdAsync(organizationId) + .Returns(organization); + + var request = new OrganizationUpdateRequest + { + OrganizationId = organizationId, + Name = "New Name", + BillingEmail = organization.BillingEmail + }; + + // Act + var result = await sutProvider.Sut.UpdateAsync(request); + + // Assert + Assert.NotNull(result); + Assert.Equal(organizationId, result.Id); + Assert.Equal("New Name", result.Name); + + await organizationService + .Received(1) + .ReplaceAndUpdateCacheAsync( + result, + EventType.Organization_Updated); + await organizationBillingService + .DidNotReceiveWithAnyArgs() + .UpdateOrganizationNameAndEmail(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_WhenKeysProvided_AndNotAlreadySet_SetsKeys( + Guid organizationId, + string publicKey, + string encryptedPrivateKey, + Organization organization, + SutProvider sutProvider) + { + // Arrange + var organizationRepository = sutProvider.GetDependency(); + var organizationService = sutProvider.GetDependency(); + + organization.Id = organizationId; + organization.PublicKey = null; + organization.PrivateKey = null; + + organizationRepository + .GetByIdAsync(organizationId) + .Returns(organization); + + var request = new OrganizationUpdateRequest + { + OrganizationId = organizationId, + Name = organization.Name, + BillingEmail = organization.BillingEmail, + PublicKey = publicKey, + EncryptedPrivateKey = encryptedPrivateKey + }; + + // Act + var result = await sutProvider.Sut.UpdateAsync(request); + + // Assert + Assert.NotNull(result); + Assert.Equal(organizationId, result.Id); + Assert.Equal(publicKey, result.PublicKey); + Assert.Equal(encryptedPrivateKey, result.PrivateKey); + + await organizationService + .Received(1) + .ReplaceAndUpdateCacheAsync( + result, + EventType.Organization_Updated); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_WhenKeysProvided_AndAlreadySet_DoesNotOverwriteKeys( + Guid organizationId, + string newPublicKey, + string newEncryptedPrivateKey, + Organization organization, + SutProvider sutProvider) + { + // Arrange + var organizationRepository = sutProvider.GetDependency(); + var organizationService = sutProvider.GetDependency(); + + organization.Id = organizationId; + var existingPublicKey = organization.PublicKey; + var existingPrivateKey = organization.PrivateKey; + + organizationRepository + .GetByIdAsync(organizationId) + .Returns(organization); + + var request = new OrganizationUpdateRequest + { + OrganizationId = organizationId, + Name = organization.Name, + BillingEmail = organization.BillingEmail, + PublicKey = newPublicKey, + EncryptedPrivateKey = newEncryptedPrivateKey + }; + + // Act + var result = await sutProvider.Sut.UpdateAsync(request); + + // Assert + Assert.NotNull(result); + Assert.Equal(organizationId, result.Id); + Assert.Equal(existingPublicKey, result.PublicKey); + Assert.Equal(existingPrivateKey, result.PrivateKey); + + await organizationService + .Received(1) + .ReplaceAndUpdateCacheAsync( + result, + EventType.Organization_Updated); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_UpdatingNameOnly_UpdatesNameAndNotBillingEmail( + Guid organizationId, + string newName, + Organization organization, + SutProvider sutProvider) + { + // Arrange + var organizationRepository = sutProvider.GetDependency(); + var organizationService = sutProvider.GetDependency(); + var organizationBillingService = sutProvider.GetDependency(); + + organization.Id = organizationId; + organization.Name = "Old Name"; + var originalBillingEmail = organization.BillingEmail; + + organizationRepository + .GetByIdAsync(organizationId) + .Returns(organization); + + var request = new OrganizationUpdateRequest + { + OrganizationId = organizationId, + Name = newName, + BillingEmail = null + }; + + // Act + var result = await sutProvider.Sut.UpdateAsync(request); + + // Assert + Assert.NotNull(result); + Assert.Equal(organizationId, result.Id); + Assert.Equal(newName, result.Name); + Assert.Equal(originalBillingEmail, result.BillingEmail); + + await organizationService + .Received(1) + .ReplaceAndUpdateCacheAsync( + result, + EventType.Organization_Updated); + await organizationBillingService + .Received(1) + .UpdateOrganizationNameAndEmail(result); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_UpdatingBillingEmailOnly_UpdatesBillingEmailAndNotName( + Guid organizationId, + string newBillingEmail, + Organization organization, + SutProvider sutProvider) + { + // Arrange + var organizationRepository = sutProvider.GetDependency(); + var organizationService = sutProvider.GetDependency(); + var organizationBillingService = sutProvider.GetDependency(); + + organization.Id = organizationId; + organization.BillingEmail = "old@example.com"; + var originalName = organization.Name; + + organizationRepository + .GetByIdAsync(organizationId) + .Returns(organization); + + var request = new OrganizationUpdateRequest + { + OrganizationId = organizationId, + Name = null, + BillingEmail = newBillingEmail + }; + + // Act + var result = await sutProvider.Sut.UpdateAsync(request); + + // Assert + Assert.NotNull(result); + Assert.Equal(organizationId, result.Id); + Assert.Equal(originalName, result.Name); + Assert.Equal(newBillingEmail.ToLowerInvariant().Trim(), result.BillingEmail); + + await organizationService + .Received(1) + .ReplaceAndUpdateCacheAsync( + result, + EventType.Organization_Updated); + await organizationBillingService + .Received(1) + .UpdateOrganizationNameAndEmail(result); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_WhenNoChanges_PreservesBothFields( + Guid organizationId, + Organization organization, + SutProvider sutProvider) + { + // Arrange + var organizationRepository = sutProvider.GetDependency(); + var organizationService = sutProvider.GetDependency(); + var organizationBillingService = sutProvider.GetDependency(); + + organization.Id = organizationId; + var originalName = organization.Name; + var originalBillingEmail = organization.BillingEmail; + + organizationRepository + .GetByIdAsync(organizationId) + .Returns(organization); + + var request = new OrganizationUpdateRequest + { + OrganizationId = organizationId, + Name = null, + BillingEmail = null + }; + + // Act + var result = await sutProvider.Sut.UpdateAsync(request); + + // Assert + Assert.NotNull(result); + Assert.Equal(organizationId, result.Id); + Assert.Equal(originalName, result.Name); + Assert.Equal(originalBillingEmail, result.BillingEmail); + + await organizationService + .Received(1) + .ReplaceAndUpdateCacheAsync( + result, + EventType.Organization_Updated); + await organizationBillingService + .DidNotReceiveWithAnyArgs() + .UpdateOrganizationNameAndEmail(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_SelfHosted_OnlyUpdatesKeysNotOrganizationDetails( + Guid organizationId, + string newName, + string newBillingEmail, + string publicKey, + string encryptedPrivateKey, + Organization organization, + SutProvider sutProvider) + { + // Arrange + var organizationBillingService = sutProvider.GetDependency(); + var globalSettings = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + + globalSettings.SelfHosted.Returns(true); + + organization.Id = organizationId; + organization.Name = "Original Name"; + organization.BillingEmail = "original@example.com"; + organization.PublicKey = null; + organization.PrivateKey = null; + + organizationRepository.GetByIdAsync(organizationId).Returns(organization); + + var request = new OrganizationUpdateRequest + { + OrganizationId = organizationId, + Name = newName, // Should be ignored + BillingEmail = newBillingEmail, // Should be ignored + PublicKey = publicKey, + EncryptedPrivateKey = encryptedPrivateKey + }; + + // Act + var result = await sutProvider.Sut.UpdateAsync(request); + + // Assert + Assert.Equal("Original Name", result.Name); // Not changed + Assert.Equal("original@example.com", result.BillingEmail); // Not changed + Assert.Equal(publicKey, result.PublicKey); // Changed + Assert.Equal(encryptedPrivateKey, result.PrivateKey); // Changed + + await organizationBillingService + .DidNotReceiveWithAnyArgs() + .UpdateOrganizationNameAndEmail(Arg.Any()); + } +} diff --git a/test/Core.Test/AdminConsole/Services/EventIntegrationHandlerTests.cs b/test/Core.Test/AdminConsole/Services/EventIntegrationHandlerTests.cs index c556c1fae0..73566cff89 100644 --- a/test/Core.Test/AdminConsole/Services/EventIntegrationHandlerTests.cs +++ b/test/Core.Test/AdminConsole/Services/EventIntegrationHandlerTests.cs @@ -14,6 +14,7 @@ using Bit.Test.Common.Helpers; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; +using ZiggyCreatures.Caching.Fusion; namespace Bit.Core.Test.Services; @@ -25,7 +26,6 @@ public class EventIntegrationHandlerTests private const string _templateWithOrganization = "Org: #OrganizationName#"; private const string _templateWithUser = "#UserName#, #UserEmail#, #UserType#"; private const string _templateWithActingUser = "#ActingUserName#, #ActingUserEmail#, #ActingUserType#"; - private static readonly Guid _groupId = Guid.NewGuid(); private static readonly Guid _organizationId = Guid.NewGuid(); private static readonly Uri _uri = new Uri("https://localhost"); private static readonly Uri _uri2 = new Uri("https://example.com"); @@ -113,6 +113,232 @@ public class EventIntegrationHandlerTests return [config]; } + [Theory, BitAutoData] + public async Task BuildContextAsync_ActingUserIdPresent_UsesCache(EventMessage eventMessage, OrganizationUserUserDetails actingUser) + { + var sutProvider = GetSutProvider(OneConfiguration(_templateWithActingUser)); + var cache = sutProvider.GetDependency(); + + eventMessage.OrganizationId ??= Guid.NewGuid(); + eventMessage.ActingUserId ??= Guid.NewGuid(); + + cache.GetOrSetAsync( + key: Arg.Any(), + factory: Arg.Any, CancellationToken, Task>>() + ).Returns(actingUser); + + var context = await sutProvider.Sut.BuildContextAsync(eventMessage, _templateWithActingUser); + + await cache.Received(1).GetOrSetAsync( + key: Arg.Any(), + factory: Arg.Any, CancellationToken, Task>>() + ); + + Assert.Equal(actingUser, context.ActingUser); + } + + [Theory, BitAutoData] + public async Task BuildContextAsync_ActingUserIdNull_SkipsCache(EventMessage eventMessage) + { + var sutProvider = GetSutProvider(OneConfiguration(_templateWithActingUser)); + var cache = sutProvider.GetDependency(); + + eventMessage.OrganizationId ??= Guid.NewGuid(); + eventMessage.ActingUserId = null; + + var context = await sutProvider.Sut.BuildContextAsync(eventMessage, _templateWithActingUser); + + await cache.DidNotReceive().GetOrSetAsync( + key: Arg.Any(), + factory: Arg.Any, CancellationToken, Task>>() + ); + Assert.Null(context.ActingUser); + } + + [Theory, BitAutoData] + public async Task BuildContextAsync_ActingUserOrganizationIdNull_SkipsCache(EventMessage eventMessage) + { + var sutProvider = GetSutProvider(OneConfiguration(_templateWithActingUser)); + var cache = sutProvider.GetDependency(); + + eventMessage.OrganizationId = null; + eventMessage.ActingUserId ??= Guid.NewGuid(); + + var context = await sutProvider.Sut.BuildContextAsync(eventMessage, _templateWithActingUser); + + await cache.DidNotReceive().GetOrSetAsync( + key: Arg.Any(), + factory: Arg.Any, CancellationToken, Task>>() + ); + Assert.Null(context.ActingUser); + } + + [Theory, BitAutoData] + public async Task BuildContextAsync_GroupIdPresent_UsesCache(EventMessage eventMessage, Group group) + { + var sutProvider = GetSutProvider(OneConfiguration(_templateWithGroup)); + var cache = sutProvider.GetDependency(); + + eventMessage.GroupId ??= Guid.NewGuid(); + + cache.GetOrSetAsync( + key: Arg.Any(), + factory: Arg.Any, CancellationToken, Task>>() + ).Returns(group); + + var context = await sutProvider.Sut.BuildContextAsync(eventMessage, _templateWithGroup); + + await cache.Received(1).GetOrSetAsync( + key: Arg.Any(), + factory: Arg.Any, CancellationToken, Task>>() + ); + Assert.Equal(group, context.Group); + } + + [Theory, BitAutoData] + public async Task BuildContextAsync_GroupIdNull_SkipsCache(EventMessage eventMessage) + { + var sutProvider = GetSutProvider(OneConfiguration(_templateWithGroup)); + var cache = sutProvider.GetDependency(); + eventMessage.GroupId = null; + + var context = await sutProvider.Sut.BuildContextAsync(eventMessage, _templateWithGroup); + + await cache.DidNotReceive().GetOrSetAsync( + key: Arg.Any(), + factory: Arg.Any, CancellationToken, Task>>() + ); + Assert.Null(context.Group); + } + + [Theory, BitAutoData] + public async Task BuildContextAsync_OrganizationIdPresent_UsesCache(EventMessage eventMessage, Organization organization) + { + var sutProvider = GetSutProvider(OneConfiguration(_templateWithOrganization)); + var cache = sutProvider.GetDependency(); + + eventMessage.OrganizationId ??= Guid.NewGuid(); + + cache.GetOrSetAsync( + key: Arg.Any(), + factory: Arg.Any, CancellationToken, Task>>() + ).Returns(organization); + + var context = await sutProvider.Sut.BuildContextAsync(eventMessage, _templateWithOrganization); + + await cache.Received(1).GetOrSetAsync( + key: Arg.Any(), + factory: Arg.Any, CancellationToken, Task>>() + ); + Assert.Equal(organization, context.Organization); + } + + [Theory, BitAutoData] + public async Task BuildContextAsync_OrganizationIdNull_SkipsCache(EventMessage eventMessage) + { + var sutProvider = GetSutProvider(OneConfiguration(_templateWithOrganization)); + var cache = sutProvider.GetDependency(); + + eventMessage.OrganizationId = null; + + var context = await sutProvider.Sut.BuildContextAsync(eventMessage, _templateWithOrganization); + + await cache.DidNotReceive().GetOrSetAsync( + key: Arg.Any(), + factory: Arg.Any, CancellationToken, Task>>() + ); + Assert.Null(context.Organization); + } + + [Theory, BitAutoData] + public async Task BuildContextAsync_UserIdPresent_UsesCache(EventMessage eventMessage, OrganizationUserUserDetails userDetails) + { + var sutProvider = GetSutProvider(OneConfiguration(_templateWithUser)); + var cache = sutProvider.GetDependency(); + + eventMessage.OrganizationId ??= Guid.NewGuid(); + eventMessage.UserId ??= Guid.NewGuid(); + + cache.GetOrSetAsync( + key: Arg.Any(), + factory: Arg.Any, CancellationToken, Task>>() + ).Returns(userDetails); + + var context = await sutProvider.Sut.BuildContextAsync(eventMessage, _templateWithUser); + + await cache.Received(1).GetOrSetAsync( + key: Arg.Any(), + factory: Arg.Any, CancellationToken, Task>>() + ); + + Assert.Equal(userDetails, context.User); + } + + + [Theory, BitAutoData] + public async Task BuildContextAsync_UserIdNull_SkipsCache(EventMessage eventMessage) + { + var sutProvider = GetSutProvider(OneConfiguration(_templateWithUser)); + var cache = sutProvider.GetDependency(); + + eventMessage.OrganizationId = null; + eventMessage.UserId ??= Guid.NewGuid(); + + var context = await sutProvider.Sut.BuildContextAsync(eventMessage, _templateWithUser); + + await cache.DidNotReceive().GetOrSetAsync( + key: Arg.Any(), + factory: Arg.Any, CancellationToken, Task>>() + ); + + Assert.Null(context.User); + } + + [Theory, BitAutoData] + public async Task BuildContextAsync_OrganizationUserIdNull_SkipsCache(EventMessage eventMessage) + { + var sutProvider = GetSutProvider(OneConfiguration(_templateWithUser)); + var cache = sutProvider.GetDependency(); + + eventMessage.OrganizationId ??= Guid.NewGuid(); + eventMessage.UserId = null; + + var context = await sutProvider.Sut.BuildContextAsync(eventMessage, _templateWithUser); + + await cache.DidNotReceive().GetOrSetAsync( + key: Arg.Any(), + factory: Arg.Any, CancellationToken, Task>>() + ); + + Assert.Null(context.User); + } + + [Theory, BitAutoData] + public async Task BuildContextAsync_NoSpecialTokens_DoesNotCallAnyCache(EventMessage eventMessage) + { + var sutProvider = GetSutProvider(OneConfiguration(_templateWithUser)); + var cache = sutProvider.GetDependency(); + + eventMessage.ActingUserId ??= Guid.NewGuid(); + eventMessage.GroupId ??= Guid.NewGuid(); + eventMessage.OrganizationId ??= Guid.NewGuid(); + eventMessage.UserId ??= Guid.NewGuid(); + + await sutProvider.Sut.BuildContextAsync(eventMessage, _templateBase); + + await cache.DidNotReceive().GetOrSetAsync( + key: Arg.Any(), + factory: Arg.Any, CancellationToken, Task>>() + ); + await cache.DidNotReceive().GetOrSetAsync( + key: Arg.Any(), + factory: Arg.Any, CancellationToken, Task>>() + ); + await cache.DidNotReceive().GetOrSetAsync( + key: Arg.Any(), + factory: Arg.Any, CancellationToken, Task>>() + ); + } [Theory, BitAutoData] public async Task HandleEventAsync_BaseTemplateNoConfigurations_DoesNothing(EventMessage eventMessage) @@ -176,99 +402,6 @@ public class EventIntegrationHandlerTests await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetDetailsByOrganizationIdUserIdAsync(Arg.Any(), Arg.Any()); } - [Theory, BitAutoData] - public async Task HandleEventAsync_ActingUserTemplate_LoadsUserFromRepository(EventMessage eventMessage) - { - var sutProvider = GetSutProvider(OneConfiguration(_templateWithActingUser)); - var user = Substitute.For(); - user.Email = "test@example.com"; - user.Name = "Test"; - eventMessage.OrganizationId = _organizationId; - - sutProvider.GetDependency() - .GetDetailsByOrganizationIdUserIdAsync(Arg.Any(), Arg.Any()).Returns(user); - await sutProvider.Sut.HandleEventAsync(eventMessage); - - var expectedMessage = EventIntegrationHandlerTests.ExpectedMessage($"{user.Name}, {user.Email}, {user.Type}"); - - Assert.Single(_eventIntegrationPublisher.ReceivedCalls()); - await _eventIntegrationPublisher.Received(1).PublishAsync(Arg.Is( - AssertHelper.AssertPropertyEqual(expectedMessage, new[] { "MessageId" }))); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any()); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any()); - await sutProvider.GetDependency().Received(1).GetDetailsByOrganizationIdUserIdAsync(Arg.Any(), eventMessage.ActingUserId ?? Guid.Empty); - } - - [Theory, BitAutoData] - public async Task HandleEventAsync_GroupTemplate_LoadsGroupFromRepository(EventMessage eventMessage) - { - var sutProvider = GetSutProvider(OneConfiguration(_templateWithGroup)); - var group = Substitute.For(); - group.Name = "Test"; - eventMessage.GroupId = _groupId; - eventMessage.OrganizationId = _organizationId; - - sutProvider.GetDependency().GetByIdAsync(Arg.Any()).Returns(group); - await sutProvider.Sut.HandleEventAsync(eventMessage); - - Assert.Single(_eventIntegrationPublisher.ReceivedCalls()); - - var expectedMessage = EventIntegrationHandlerTests.ExpectedMessage($"Group: {group.Name}"); - - Assert.Single(_eventIntegrationPublisher.ReceivedCalls()); - await _eventIntegrationPublisher.Received(1).PublishAsync(Arg.Is( - AssertHelper.AssertPropertyEqual(expectedMessage, new[] { "MessageId" }))); - await sutProvider.GetDependency().Received(1).GetByIdAsync(eventMessage.GroupId ?? Guid.Empty); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any()); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetDetailsByOrganizationIdUserIdAsync(Arg.Any(), Arg.Any()); - } - - [Theory, BitAutoData] - public async Task HandleEventAsync_OrganizationTemplate_LoadsOrganizationFromRepository(EventMessage eventMessage) - { - var sutProvider = GetSutProvider(OneConfiguration(_templateWithOrganization)); - var organization = Substitute.For(); - organization.Name = "Test"; - eventMessage.OrganizationId = _organizationId; - - sutProvider.GetDependency().GetByIdAsync(Arg.Any()).Returns(organization); - await sutProvider.Sut.HandleEventAsync(eventMessage); - - Assert.Single(_eventIntegrationPublisher.ReceivedCalls()); - - var expectedMessage = EventIntegrationHandlerTests.ExpectedMessage($"Org: {organization.Name}"); - - Assert.Single(_eventIntegrationPublisher.ReceivedCalls()); - await _eventIntegrationPublisher.Received(1).PublishAsync(Arg.Is( - AssertHelper.AssertPropertyEqual(expectedMessage, new[] { "MessageId" }))); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any()); - await sutProvider.GetDependency().Received(1).GetByIdAsync(eventMessage.OrganizationId ?? Guid.Empty); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetDetailsByOrganizationIdUserIdAsync(Arg.Any(), Arg.Any()); - } - - [Theory, BitAutoData] - public async Task HandleEventAsync_UserTemplate_LoadsUserFromRepository(EventMessage eventMessage) - { - var sutProvider = GetSutProvider(OneConfiguration(_templateWithUser)); - var user = Substitute.For(); - user.Email = "test@example.com"; - user.Name = "Test"; - eventMessage.OrganizationId = _organizationId; - - sutProvider.GetDependency() - .GetDetailsByOrganizationIdUserIdAsync(Arg.Any(), Arg.Any()).Returns(user); - await sutProvider.Sut.HandleEventAsync(eventMessage); - - var expectedMessage = EventIntegrationHandlerTests.ExpectedMessage($"{user.Name}, {user.Email}, {user.Type}"); - - Assert.Single(_eventIntegrationPublisher.ReceivedCalls()); - await _eventIntegrationPublisher.Received(1).PublishAsync(Arg.Is( - AssertHelper.AssertPropertyEqual(expectedMessage, new[] { "MessageId" }))); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any()); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any()); - await sutProvider.GetDependency().Received(1).GetDetailsByOrganizationIdUserIdAsync(Arg.Any(), eventMessage.UserId ?? Guid.Empty); - } - [Theory, BitAutoData] public async Task HandleEventAsync_FilterReturnsFalse_DoesNothing(EventMessage eventMessage) { diff --git a/test/Core.Test/Auth/UserFeatures/Registration/SendVerificationEmailForRegistrationCommandTests.cs b/test/Core.Test/Auth/UserFeatures/Registration/SendVerificationEmailForRegistrationCommandTests.cs index bb4bce08c1..91e8351d2c 100644 --- a/test/Core.Test/Auth/UserFeatures/Registration/SendVerificationEmailForRegistrationCommandTests.cs +++ b/test/Core.Test/Auth/UserFeatures/Registration/SendVerificationEmailForRegistrationCommandTests.cs @@ -1,4 +1,5 @@ -using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Auth.Models.Api.Request.Accounts; +using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.UserFeatures.Registration.Implementations; using Bit.Core.Entities; using Bit.Core.Exceptions; @@ -40,22 +41,55 @@ public class SendVerificationEmailForRegistrationCommandTests .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any()) .Returns(false); - sutProvider.GetDependency() - .SendRegistrationVerificationEmailAsync(email, Arg.Any()) - .Returns(Task.CompletedTask); - var mockedToken = "token"; sutProvider.GetDependency>() .Protect(Arg.Any()) .Returns(mockedToken); // Act - var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails); + var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails, null); // Assert await sutProvider.GetDependency() .Received(1) - .SendRegistrationVerificationEmailAsync(email, mockedToken); + .SendRegistrationVerificationEmailAsync(email, mockedToken, null); + Assert.Null(result); + } + + [Theory] + [BitAutoData] + public async Task SendVerificationEmailForRegistrationCommand_WhenFromMarketingIsPremium_SendsEmailWithMarketingParameterAndReturnsNull(SutProvider sutProvider, + string email, string name, bool receiveMarketingEmails) + { + // Arrange + sutProvider.GetDependency() + .GetByEmailAsync(email) + .ReturnsNull(); + + sutProvider.GetDependency() + .EnableEmailVerification = true; + + sutProvider.GetDependency() + .DisableUserRegistration = false; + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any()) + .Returns(false); + + var mockedToken = "token"; + sutProvider.GetDependency>() + .Protect(Arg.Any()) + .Returns(mockedToken); + + var fromMarketing = MarketingInitiativeConstants.Premium; + + // Act + var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails, fromMarketing); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SendRegistrationVerificationEmailAsync(email, mockedToken, fromMarketing); Assert.Null(result); } @@ -87,12 +121,12 @@ public class SendVerificationEmailForRegistrationCommandTests .Returns(mockedToken); // Act - var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails); + var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails, null); // Assert await sutProvider.GetDependency() .DidNotReceive() - .SendRegistrationVerificationEmailAsync(email, mockedToken); + .SendRegistrationVerificationEmailAsync(email, mockedToken, null); Assert.Null(result); } @@ -124,7 +158,7 @@ public class SendVerificationEmailForRegistrationCommandTests .Returns(mockedToken); // Act - var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails); + var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails, null); // Assert Assert.Equal(mockedToken, result); @@ -140,7 +174,7 @@ public class SendVerificationEmailForRegistrationCommandTests .DisableUserRegistration = true; // Act & Assert - await Assert.ThrowsAsync(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails)); + await Assert.ThrowsAsync(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails, null)); } [Theory] @@ -166,7 +200,7 @@ public class SendVerificationEmailForRegistrationCommandTests .Returns(false); // Act & Assert - await Assert.ThrowsAsync(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails)); + await Assert.ThrowsAsync(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails, null)); } [Theory] @@ -177,7 +211,7 @@ public class SendVerificationEmailForRegistrationCommandTests sutProvider.GetDependency() .DisableUserRegistration = false; - await Assert.ThrowsAsync(async () => await sutProvider.Sut.Run(null, name, receiveMarketingEmails)); + await Assert.ThrowsAsync(async () => await sutProvider.Sut.Run(null, name, receiveMarketingEmails, null)); } [Theory] @@ -187,7 +221,7 @@ public class SendVerificationEmailForRegistrationCommandTests { sutProvider.GetDependency() .DisableUserRegistration = false; - await Assert.ThrowsAsync(async () => await sutProvider.Sut.Run("", name, receiveMarketingEmails)); + await Assert.ThrowsAsync(async () => await sutProvider.Sut.Run("", name, receiveMarketingEmails, null)); } [Theory] @@ -210,7 +244,7 @@ public class SendVerificationEmailForRegistrationCommandTests .Returns(true); // Act & Assert - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails, null)); Assert.Equal("This email address is claimed by an organization using Bitwarden.", exception.Message); } @@ -246,7 +280,7 @@ public class SendVerificationEmailForRegistrationCommandTests .Returns(mockedToken); // Act - var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails); + var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails, null); // Assert Assert.Equal(mockedToken, result); @@ -270,7 +304,7 @@ public class SendVerificationEmailForRegistrationCommandTests // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.Run(email, name, receiveMarketingEmails)); + sutProvider.Sut.Run(email, name, receiveMarketingEmails, null)); Assert.Equal("Invalid email address format.", exception.Message); } } diff --git a/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs b/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs index 6a7e9d3190..4060b45528 100644 --- a/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs +++ b/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs @@ -1,4 +1,5 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models.Sales; @@ -353,4 +354,97 @@ public class OrganizationBillingServiceTests } #endregion + + [Theory, BitAutoData] + public async Task UpdateOrganizationNameAndEmail_UpdatesStripeCustomer( + Organization organization, + SutProvider sutProvider) + { + organization.Name = "Short name"; + + CustomerUpdateOptions capturedOptions = null; + sutProvider.GetDependency() + .CustomerUpdateAsync( + Arg.Is(id => id == organization.GatewayCustomerId), + Arg.Do(options => capturedOptions = options)) + .Returns(new Customer()); + + // Act + await sutProvider.Sut.UpdateOrganizationNameAndEmail(organization); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .CustomerUpdateAsync( + organization.GatewayCustomerId, + Arg.Any()); + + Assert.NotNull(capturedOptions); + Assert.Equal(organization.BillingEmail, capturedOptions.Email); + Assert.Equal(organization.DisplayName(), capturedOptions.Description); + Assert.NotNull(capturedOptions.InvoiceSettings); + Assert.NotNull(capturedOptions.InvoiceSettings.CustomFields); + Assert.Single(capturedOptions.InvoiceSettings.CustomFields); + + var customField = capturedOptions.InvoiceSettings.CustomFields.First(); + Assert.Equal(organization.SubscriberType(), customField.Name); + Assert.Equal(organization.DisplayName(), customField.Value); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationNameAndEmail_WhenNameIsLong_TruncatesTo30Characters( + Organization organization, + SutProvider sutProvider) + { + // Arrange + organization.Name = "This is a very long organization name that exceeds thirty characters"; + + CustomerUpdateOptions capturedOptions = null; + sutProvider.GetDependency() + .CustomerUpdateAsync( + Arg.Is(id => id == organization.GatewayCustomerId), + Arg.Do(options => capturedOptions = options)) + .Returns(new Customer()); + + // Act + await sutProvider.Sut.UpdateOrganizationNameAndEmail(organization); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .CustomerUpdateAsync( + organization.GatewayCustomerId, + Arg.Any()); + + Assert.NotNull(capturedOptions); + Assert.NotNull(capturedOptions.InvoiceSettings); + Assert.NotNull(capturedOptions.InvoiceSettings.CustomFields); + + var customField = capturedOptions.InvoiceSettings.CustomFields.First(); + Assert.Equal(30, customField.Value.Length); + + var expectedCustomFieldDisplayName = "This is a very long organizati"; + Assert.Equal(expectedCustomFieldDisplayName, customField.Value); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationNameAndEmail_WhenGatewayCustomerIdIsNull_ThrowsBillingException( + Organization organization, + SutProvider sutProvider) + { + // Arrange + organization.GatewayCustomerId = null; + organization.Name = "Test Organization"; + organization.BillingEmail = "billing@example.com"; + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateOrganizationNameAndEmail(organization)); + + Assert.Contains("Cannot update an organization in Stripe without a GatewayCustomerId.", exception.Response); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .CustomerUpdateAsync(Arg.Any(), Arg.Any()); + } } diff --git a/test/Core.Test/Utilities/EventIntegrationsCacheConstantsTests.cs b/test/Core.Test/Utilities/EventIntegrationsCacheConstantsTests.cs new file mode 100644 index 0000000000..051801e505 --- /dev/null +++ b/test/Core.Test/Utilities/EventIntegrationsCacheConstantsTests.cs @@ -0,0 +1,41 @@ +using Bit.Core.Utilities; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.Utilities; + +public class EventIntegrationsCacheConstantsTests +{ + [Theory, BitAutoData] + public void BuildCacheKeyForGroup_ReturnsExpectedKey(Guid groupId) + { + var expected = $"Group:{groupId:N}"; + var key = EventIntegrationsCacheConstants.BuildCacheKeyForGroup(groupId); + + Assert.Equal(expected, key); + } + + [Theory, BitAutoData] + public void BuildCacheKeyForOrganization_ReturnsExpectedKey(Guid orgId) + { + var expected = $"Organization:{orgId:N}"; + var key = EventIntegrationsCacheConstants.BuildCacheKeyForOrganization(orgId); + + Assert.Equal(expected, key); + } + + [Theory, BitAutoData] + public void BuildCacheKeyForOrganizationUser_ReturnsExpectedKey(Guid orgId, Guid userId) + { + var expected = $"OrganizationUserUserDetails:{orgId:N}:{userId:N}"; + var key = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationUser(orgId, userId); + + Assert.Equal(expected, key); + } + + [Fact] + public void CacheName_ReturnsExpected() + { + Assert.Equal("EventIntegrations", EventIntegrationsCacheConstants.CacheName); + } +} diff --git a/test/Identity.Test/Controllers/AccountsControllerTests.cs b/test/Identity.Test/Controllers/AccountsControllerTests.cs index d089c8ec57..42e033bdd7 100644 --- a/test/Identity.Test/Controllers/AccountsControllerTests.cs +++ b/test/Identity.Test/Controllers/AccountsControllerTests.cs @@ -241,7 +241,7 @@ public class AccountsControllerTests : IDisposable var token = "fakeToken"; - _sendVerificationEmailForRegistrationCommand.Run(email, name, receiveMarketingEmails).Returns(token); + _sendVerificationEmailForRegistrationCommand.Run(email, name, receiveMarketingEmails, null).Returns(token); // Act var result = await _sut.PostRegisterSendVerificationEmail(model); @@ -264,7 +264,7 @@ public class AccountsControllerTests : IDisposable ReceiveMarketingEmails = receiveMarketingEmails }; - _sendVerificationEmailForRegistrationCommand.Run(email, name, receiveMarketingEmails).ReturnsNull(); + _sendVerificationEmailForRegistrationCommand.Run(email, name, receiveMarketingEmails, null).ReturnsNull(); // Act var result = await _sut.PostRegisterSendVerificationEmail(model); @@ -274,6 +274,55 @@ public class AccountsControllerTests : IDisposable Assert.Equal(204, noContentResult.StatusCode); } + [Theory] + [BitAutoData] + public async Task PostRegisterSendEmailVerification_WhenFeatureFlagEnabled_PassesFromMarketingToCommandAsync( + string email, string name, bool receiveMarketingEmails) + { + // Arrange + var fromMarketing = MarketingInitiativeConstants.Premium; + var model = new RegisterSendVerificationEmailRequestModel + { + Email = email, + Name = name, + ReceiveMarketingEmails = receiveMarketingEmails, + FromMarketing = fromMarketing, + }; + + _featureService.IsEnabled(FeatureFlagKeys.MarketingInitiatedPremiumFlow).Returns(true); + + // Act + await _sut.PostRegisterSendVerificationEmail(model); + + // Assert + await _sendVerificationEmailForRegistrationCommand.Received(1) + .Run(email, name, receiveMarketingEmails, fromMarketing); + } + + [Theory] + [BitAutoData] + public async Task PostRegisterSendEmailVerification_WhenFeatureFlagDisabled_PassesNullFromMarketingToCommandAsync( + string email, string name, bool receiveMarketingEmails) + { + // Arrange + var model = new RegisterSendVerificationEmailRequestModel + { + Email = email, + Name = name, + ReceiveMarketingEmails = receiveMarketingEmails, + FromMarketing = MarketingInitiativeConstants.Premium, // model includes FromMarketing: "premium" + }; + + _featureService.IsEnabled(FeatureFlagKeys.MarketingInitiatedPremiumFlow).Returns(false); + + // Act + await _sut.PostRegisterSendVerificationEmail(model); + + // Assert + await _sendVerificationEmailForRegistrationCommand.Received(1) + .Run(email, name, receiveMarketingEmails, null); // fromMarketing gets ignored and null gets passed + } + [Theory, BitAutoData] public async Task PostRegisterFinish_WhenGivenOrgInvite_ShouldRegisterUser( string email, string masterPasswordHash, string orgInviteToken, Guid organizationUserId, string userSymmetricKey, diff --git a/test/Infrastructure.Dapper.Test/Infrastructure.Dapper.Test.csproj b/test/Infrastructure.Dapper.Test/Infrastructure.Dapper.Test.csproj index fdef3c6cac..a7fdfa2df5 100644 --- a/test/Infrastructure.Dapper.Test/Infrastructure.Dapper.Test.csproj +++ b/test/Infrastructure.Dapper.Test/Infrastructure.Dapper.Test.csproj @@ -10,7 +10,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs b/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs index 97a836cf44..3c0b551908 100644 --- a/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs +++ b/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs @@ -35,7 +35,7 @@ public class IdentityApplicationFactory : WebApplicationFactoryBase // This allows us to use the official registration flow SubstituteService(service => { - service.SendRegistrationVerificationEmailAsync(Arg.Any(), Arg.Any()) + service.SendRegistrationVerificationEmailAsync(Arg.Any(), Arg.Any(), Arg.Any()) .ReturnsForAnyArgs(Task.CompletedTask) .AndDoes(call => { diff --git a/util/Migrator/DbScripts/2025-11-24_00_CipherDetailsCreateWithCollections.sql b/util/Migrator/DbScripts/2025-11-24_00_CipherDetailsCreateWithCollections.sql new file mode 100644 index 0000000000..c07bbc9799 --- /dev/null +++ b/util/Migrator/DbScripts/2025-11-24_00_CipherDetailsCreateWithCollections.sql @@ -0,0 +1,39 @@ +CREATE OR ALTER PROCEDURE [dbo].[CipherDetails_CreateWithCollections] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Type TINYINT, + @Data NVARCHAR(MAX), + @Favorites NVARCHAR(MAX), -- not used + @Folders NVARCHAR(MAX), -- not used + @Attachments NVARCHAR(MAX), -- not used + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @FolderId UNIQUEIDENTIFIER, + @Favorite BIT, + @Edit BIT, -- not used + @ViewPassword BIT, -- not used + @Manage BIT, -- not used + @OrganizationUseTotp BIT, -- not used + @DeletedDate DATETIME2(7), + @Reprompt TINYINT, + @Key VARCHAR(MAX) = NULL, + @CollectionIds AS [dbo].[GuidIdArray] READONLY, + @ArchivedDate DATETIME2(7) = NULL +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[CipherDetails_Create] @Id, @UserId, @OrganizationId, @Type, @Data, @Favorites, @Folders, + @Attachments, @CreationDate, @RevisionDate, @FolderId, @Favorite, @Edit, @ViewPassword, @Manage, + @OrganizationUseTotp, @DeletedDate, @Reprompt, @Key, @ArchivedDate + + DECLARE @UpdateCollectionsSuccess INT + EXEC @UpdateCollectionsSuccess = [dbo].[Cipher_UpdateCollections] @Id, @UserId, @OrganizationId, @CollectionIds + + -- Bump the account revision date AFTER collections are assigned. + IF @UpdateCollectionsSuccess = 0 + BEGIN + EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId + END +END