From 4aed97b76b4fec0a383524575dcec61dcb0b0a68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Thu, 6 Nov 2025 11:35:07 +0000 Subject: [PATCH 01/77] [PM-26690] Wire VNextSavePolicyCommand behind PolicyValidatorsRefactor feature flag (#6483) * Add PolicyValidatorsRefactor constant to FeatureFlagKeys in Constants.cs * Add Metadata property and ToSavePolicyModel method to PolicyUpdateRequestModel * Refactor PoliciesController to utilize IVNextSavePolicyCommand based on feature flag - Added IFeatureService and IVNextSavePolicyCommand dependencies to PoliciesController. - Updated PutVNext method to conditionally use VNextSavePolicyCommand or SavePolicyCommand based on the PolicyValidatorsRefactor feature flag. - Enhanced unit tests to verify behavior for both enabled and disabled states of the feature flag. * Update public PoliciesController to to utilize IVNextSavePolicyCommand based on feature flag - Introduced IFeatureService and IVNextSavePolicyCommand to manage policy saving based on the PolicyValidatorsRefactor feature flag. - Updated the Put method to conditionally use the new VNextSavePolicyCommand or the legacy SavePolicyCommand. - Added unit tests to validate the behavior of the Put method for both enabled and disabled states of the feature flag. * Refactor VerifyOrganizationDomainCommand to utilize IVNextSavePolicyCommand based on feature flag - Added IFeatureService and IVNextSavePolicyCommand dependencies to VerifyOrganizationDomainCommand. - Updated EnableSingleOrganizationPolicyAsync method to conditionally use VNextSavePolicyCommand or SavePolicyCommand based on the PolicyValidatorsRefactor feature flag. - Enhanced unit tests to validate the behavior when the feature flag is enabled. * Enhance SsoConfigService to utilize IVNextSavePolicyCommand based on feature flag - Added IFeatureService and IVNextSavePolicyCommand dependencies to SsoConfigService. - Updated SaveAsync method to conditionally use VNextSavePolicyCommand or SavePolicyCommand based on the PolicyValidatorsRefactor feature flag. - Added unit tests to validate the behavior when the feature flag is enabled. * Refactor SavePolicyModel to simplify constructor usage by removing EmptyMetadataModel parameter. Update related usages across the codebase to reflect the new constructor overloads. * Update PolicyUpdateRequestModel to make Metadata property nullable for improved null safety --- .../Controllers/PoliciesController.cs | 14 ++- .../Public/Controllers/PoliciesController.cs | 25 ++++- .../Request/PolicyUpdateRequestModel.cs | 20 ++++ .../VerifyOrganizationDomainCommand.cs | 32 ++++-- .../Policies/Models/SavePolicyModel.cs | 14 +++ .../Implementations/SsoConfigService.cs | 34 +++++-- src/Core/Constants.cs | 1 + .../Controllers/PoliciesControllerTests.cs | 87 ++++++++++++++++ .../Controllers/PoliciesControllerTests.cs | 99 +++++++++++++++++++ .../VerifyOrganizationDomainCommandTests.cs | 32 ++++++ ...miliesForEnterprisePolicyValidatorTests.cs | 4 +- ...zationDataOwnershipPolicyValidatorTests.cs | 24 ++--- .../RequireSsoPolicyValidatorTests.cs | 6 +- .../ResetPasswordPolicyValidatorTests.cs | 4 +- .../SingleOrgPolicyValidatorTests.cs | 6 +- ...actorAuthenticationPolicyValidatorTests.cs | 4 +- .../Policies/SavePolicyCommandTests.cs | 4 +- .../Policies/VNextSavePolicyCommandTests.cs | 22 ++--- .../Auth/Services/SsoConfigServiceTests.cs | 53 ++++++++++ 19 files changed, 426 insertions(+), 59 deletions(-) create mode 100644 test/Api.Test/AdminConsole/Public/Controllers/PoliciesControllerTests.cs diff --git a/src/Api/AdminConsole/Controllers/PoliciesController.cs b/src/Api/AdminConsole/Controllers/PoliciesController.cs index ce92321833..1ee6dedf89 100644 --- a/src/Api/AdminConsole/Controllers/PoliciesController.cs +++ b/src/Api/AdminConsole/Controllers/PoliciesController.cs @@ -12,6 +12,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Context; @@ -41,8 +42,9 @@ public class PoliciesController : Controller private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; private readonly IPolicyRepository _policyRepository; private readonly IUserService _userService; - + private readonly IFeatureService _featureService; private readonly ISavePolicyCommand _savePolicyCommand; + private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand; public PoliciesController(IPolicyRepository policyRepository, IOrganizationUserRepository organizationUserRepository, @@ -53,7 +55,9 @@ public class PoliciesController : Controller IDataProtectorTokenFactory orgUserInviteTokenDataFactory, IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery, IOrganizationRepository organizationRepository, - ISavePolicyCommand savePolicyCommand) + IFeatureService featureService, + ISavePolicyCommand savePolicyCommand, + IVNextSavePolicyCommand vNextSavePolicyCommand) { _policyRepository = policyRepository; _organizationUserRepository = organizationUserRepository; @@ -65,7 +69,9 @@ public class PoliciesController : Controller _organizationRepository = organizationRepository; _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; _organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery; + _featureService = featureService; _savePolicyCommand = savePolicyCommand; + _vNextSavePolicyCommand = vNextSavePolicyCommand; } [HttpGet("{type}")] @@ -221,7 +227,9 @@ public class PoliciesController : Controller { var savePolicyRequest = await model.ToSavePolicyModelAsync(orgId, _currentContext); - var policy = await _savePolicyCommand.VNextSaveAsync(savePolicyRequest); + var policy = _featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) ? + await _vNextSavePolicyCommand.SaveAsync(savePolicyRequest) : + await _savePolicyCommand.VNextSaveAsync(savePolicyRequest); return new PolicyResponseModel(policy); } diff --git a/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs b/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs index 1caf9cb068..be0997f271 100644 --- a/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs +++ b/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs @@ -5,11 +5,15 @@ using System.Net; using Bit.Api.AdminConsole.Public.Models.Request; using Bit.Api.AdminConsole.Public.Models.Response; using Bit.Api.Models.Public.Response; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Context; +using Bit.Core.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -22,18 +26,24 @@ public class PoliciesController : Controller private readonly IPolicyRepository _policyRepository; private readonly IPolicyService _policyService; private readonly ICurrentContext _currentContext; + private readonly IFeatureService _featureService; private readonly ISavePolicyCommand _savePolicyCommand; + private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand; public PoliciesController( IPolicyRepository policyRepository, IPolicyService policyService, ICurrentContext currentContext, - ISavePolicyCommand savePolicyCommand) + IFeatureService featureService, + ISavePolicyCommand savePolicyCommand, + IVNextSavePolicyCommand vNextSavePolicyCommand) { _policyRepository = policyRepository; _policyService = policyService; _currentContext = currentContext; + _featureService = featureService; _savePolicyCommand = savePolicyCommand; + _vNextSavePolicyCommand = vNextSavePolicyCommand; } /// @@ -87,8 +97,17 @@ public class PoliciesController : Controller [ProducesResponseType((int)HttpStatusCode.NotFound)] public async Task Put(PolicyType type, [FromBody] PolicyUpdateRequestModel model) { - var policyUpdate = model.ToPolicyUpdate(_currentContext.OrganizationId!.Value, type); - var policy = await _savePolicyCommand.SaveAsync(policyUpdate); + Policy policy; + if (_featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor)) + { + var savePolicyModel = model.ToSavePolicyModel(_currentContext.OrganizationId!.Value, type); + policy = await _vNextSavePolicyCommand.SaveAsync(savePolicyModel); + } + else + { + var policyUpdate = model.ToPolicyUpdate(_currentContext.OrganizationId!.Value, type); + policy = await _savePolicyCommand.SaveAsync(policyUpdate); + } var response = new PolicyResponseModel(policy); return new JsonResult(response); diff --git a/src/Api/AdminConsole/Public/Models/Request/PolicyUpdateRequestModel.cs b/src/Api/AdminConsole/Public/Models/Request/PolicyUpdateRequestModel.cs index 34675a6046..f81d9153b2 100644 --- a/src/Api/AdminConsole/Public/Models/Request/PolicyUpdateRequestModel.cs +++ b/src/Api/AdminConsole/Public/Models/Request/PolicyUpdateRequestModel.cs @@ -8,6 +8,8 @@ namespace Bit.Api.AdminConsole.Public.Models.Request; public class PolicyUpdateRequestModel : PolicyBaseModel { + public Dictionary? Metadata { get; set; } + public PolicyUpdate ToPolicyUpdate(Guid organizationId, PolicyType type) { var serializedData = PolicyDataValidator.ValidateAndSerialize(Data, type); @@ -21,4 +23,22 @@ public class PolicyUpdateRequestModel : PolicyBaseModel PerformedBy = new SystemUser(EventSystemUser.PublicApi) }; } + + public SavePolicyModel ToSavePolicyModel(Guid organizationId, PolicyType type) + { + var serializedData = PolicyDataValidator.ValidateAndSerialize(Data, type); + + var policyUpdate = new PolicyUpdate + { + Type = type, + OrganizationId = organizationId, + Data = serializedData, + Enabled = Enabled.GetValueOrDefault() + }; + + var performedBy = new SystemUser(EventSystemUser.PublicApi); + var metadata = PolicyDataValidator.ValidateAndDeserializeMetadata(Metadata, type); + + return new SavePolicyModel(policyUpdate, performedBy, metadata); + } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs index c03341bbc0..595e487580 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs @@ -6,6 +6,7 @@ using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -24,7 +25,9 @@ public class VerifyOrganizationDomainCommand( IEventService eventService, IGlobalSettings globalSettings, ICurrentContext currentContext, + IFeatureService featureService, ISavePolicyCommand savePolicyCommand, + IVNextSavePolicyCommand vNextSavePolicyCommand, IMailService mailService, IOrganizationUserRepository organizationUserRepository, IOrganizationRepository organizationRepository, @@ -131,15 +134,26 @@ public class VerifyOrganizationDomainCommand( await SendVerifiedDomainUserEmailAsync(domain); } - private async Task EnableSingleOrganizationPolicyAsync(Guid organizationId, IActingUser actingUser) => - await savePolicyCommand.SaveAsync( - new PolicyUpdate - { - OrganizationId = organizationId, - Type = PolicyType.SingleOrg, - Enabled = true, - PerformedBy = actingUser - }); + private async Task EnableSingleOrganizationPolicyAsync(Guid organizationId, IActingUser actingUser) + { + var policyUpdate = new PolicyUpdate + { + OrganizationId = organizationId, + Type = PolicyType.SingleOrg, + Enabled = true, + PerformedBy = actingUser + }; + + if (featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor)) + { + var savePolicyModel = new SavePolicyModel(policyUpdate, actingUser); + await vNextSavePolicyCommand.SaveAsync(savePolicyModel); + } + else + { + await savePolicyCommand.SaveAsync(policyUpdate); + } + } private async Task SendVerifiedDomainUserEmailAsync(OrganizationDomain domain) { diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/SavePolicyModel.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/SavePolicyModel.cs index 7c8d5126e8..01168deea4 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/SavePolicyModel.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/SavePolicyModel.cs @@ -5,4 +5,18 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; public record SavePolicyModel(PolicyUpdate PolicyUpdate, IActingUser? PerformedBy, IPolicyMetadataModel Metadata) { + public SavePolicyModel(PolicyUpdate PolicyUpdate) + : this(PolicyUpdate, null, new EmptyMetadataModel()) + { + } + + public SavePolicyModel(PolicyUpdate PolicyUpdate, IActingUser performedBy) + : this(PolicyUpdate, performedBy, new EmptyMetadataModel()) + { + } + + public SavePolicyModel(PolicyUpdate PolicyUpdate, IPolicyMetadataModel metadata) + : this(PolicyUpdate, null, metadata) + { + } } diff --git a/src/Core/Auth/Services/Implementations/SsoConfigService.cs b/src/Core/Auth/Services/Implementations/SsoConfigService.cs index fe8d9bdd6e..1a35585b2c 100644 --- a/src/Core/Auth/Services/Implementations/SsoConfigService.cs +++ b/src/Core/Auth/Services/Implementations/SsoConfigService.cs @@ -3,9 +3,11 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; @@ -24,7 +26,9 @@ public class SsoConfigService : ISsoConfigService private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IEventService _eventService; + private readonly IFeatureService _featureService; private readonly ISavePolicyCommand _savePolicyCommand; + private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand; public SsoConfigService( ISsoConfigRepository ssoConfigRepository, @@ -32,14 +36,18 @@ public class SsoConfigService : ISsoConfigService IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, IEventService eventService, - ISavePolicyCommand savePolicyCommand) + IFeatureService featureService, + ISavePolicyCommand savePolicyCommand, + IVNextSavePolicyCommand vNextSavePolicyCommand) { _ssoConfigRepository = ssoConfigRepository; _policyRepository = policyRepository; _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; _eventService = eventService; + _featureService = featureService; _savePolicyCommand = savePolicyCommand; + _vNextSavePolicyCommand = vNextSavePolicyCommand; } public async Task SaveAsync(SsoConfig config, Organization organization) @@ -67,13 +75,12 @@ public class SsoConfigService : ISsoConfigService // Automatically enable account recovery, SSO required, and single org policies if trusted device encryption is selected if (config.GetData().MemberDecryptionType == MemberDecryptionType.TrustedDeviceEncryption) { - - await _savePolicyCommand.SaveAsync(new() + var singleOrgPolicy = new PolicyUpdate { OrganizationId = config.OrganizationId, Type = PolicyType.SingleOrg, Enabled = true - }); + }; var resetPasswordPolicy = new PolicyUpdate { @@ -82,14 +89,27 @@ public class SsoConfigService : ISsoConfigService Enabled = true, }; resetPasswordPolicy.SetDataModel(new ResetPasswordDataModel { AutoEnrollEnabled = true }); - await _savePolicyCommand.SaveAsync(resetPasswordPolicy); - await _savePolicyCommand.SaveAsync(new() + var requireSsoPolicy = new PolicyUpdate { OrganizationId = config.OrganizationId, Type = PolicyType.RequireSso, Enabled = true - }); + }; + + if (_featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor)) + { + var performedBy = new SystemUser(EventSystemUser.Unknown); + await _vNextSavePolicyCommand.SaveAsync(new SavePolicyModel(singleOrgPolicy, performedBy)); + await _vNextSavePolicyCommand.SaveAsync(new SavePolicyModel(resetPasswordPolicy, performedBy)); + await _vNextSavePolicyCommand.SaveAsync(new SavePolicyModel(requireSsoPolicy, performedBy)); + } + else + { + await _savePolicyCommand.SaveAsync(singleOrgPolicy); + await _savePolicyCommand.SaveAsync(resetPasswordPolicy); + await _savePolicyCommand.SaveAsync(requireSsoPolicy); + } } await LogEventsAsync(config, oldConfig); diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 78f1db5228..d2d1062761 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -143,6 +143,7 @@ public static class FeatureFlagKeys public const string AutomaticConfirmUsers = "pm-19934-auto-confirm-organization-users"; public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache"; public const string AccountRecoveryCommand = "pm-25581-prevent-provider-account-recovery"; + public const string PolicyValidatorsRefactor = "pm-26423-refactor-policy-side-effects"; /* Auth Team */ public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence"; diff --git a/test/Api.Test/AdminConsole/Public/Controllers/PoliciesControllerTests.cs b/test/Api.Test/AdminConsole/Public/Controllers/PoliciesControllerTests.cs new file mode 100644 index 0000000000..c2360f5f9a --- /dev/null +++ b/test/Api.Test/AdminConsole/Public/Controllers/PoliciesControllerTests.cs @@ -0,0 +1,87 @@ +using Bit.Api.AdminConsole.Public.Controllers; +using Bit.Api.AdminConsole.Public.Models.Request; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; +using Bit.Core.Context; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.AdminConsole.Public.Controllers; + +[ControllerCustomize(typeof(PoliciesController))] +[SutProviderCustomize] +public class PoliciesControllerTests +{ + [Theory] + [BitAutoData] + public async Task Put_WhenPolicyValidatorsRefactorEnabled_UsesVNextSavePolicyCommand( + Guid organizationId, + PolicyType policyType, + PolicyUpdateRequestModel model, + Policy policy, + SutProvider sutProvider) + { + // Arrange + policy.Data = null; + sutProvider.GetDependency() + .OrganizationId.Returns(organizationId); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) + .Returns(true); + sutProvider.GetDependency() + .SaveAsync(Arg.Any()) + .Returns(policy); + + // Act + await sutProvider.Sut.Put(policyType, model); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SaveAsync(Arg.Is(m => + m.PolicyUpdate.OrganizationId == organizationId && + m.PolicyUpdate.Type == policyType && + m.PolicyUpdate.Enabled == model.Enabled.GetValueOrDefault() && + m.PerformedBy is SystemUser)); + } + + [Theory] + [BitAutoData] + public async Task Put_WhenPolicyValidatorsRefactorDisabled_UsesLegacySavePolicyCommand( + Guid organizationId, + PolicyType policyType, + PolicyUpdateRequestModel model, + Policy policy, + SutProvider sutProvider) + { + // Arrange + policy.Data = null; + sutProvider.GetDependency() + .OrganizationId.Returns(organizationId); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) + .Returns(false); + sutProvider.GetDependency() + .SaveAsync(Arg.Any()) + .Returns(policy); + + // Act + await sutProvider.Sut.Put(policyType, model); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SaveAsync(Arg.Is(p => + p.OrganizationId == organizationId && + p.Type == policyType && + p.Enabled == model.Enabled)); + } +} diff --git a/test/Api.Test/Controllers/PoliciesControllerTests.cs b/test/Api.Test/Controllers/PoliciesControllerTests.cs index f5f3eddd3b..73cdd0fe29 100644 --- a/test/Api.Test/Controllers/PoliciesControllerTests.cs +++ b/test/Api.Test/Controllers/PoliciesControllerTests.cs @@ -1,10 +1,15 @@ using System.Security.Claims; using System.Text.Json; using Bit.Api.AdminConsole.Controllers; +using Bit.Api.AdminConsole.Models.Request; using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Context; @@ -455,4 +460,98 @@ public class PoliciesControllerTests Assert.Equal(enabledPolicy.Type, expectedPolicy.Type); Assert.Equal(enabledPolicy.Enabled, expectedPolicy.Enabled); } + + [Theory] + [BitAutoData] + public async Task PutVNext_WhenPolicyValidatorsRefactorEnabled_UsesVNextSavePolicyCommand( + SutProvider sutProvider, Guid orgId, + SavePolicyRequest model, Policy policy, Guid userId) + { + // Arrange + policy.Data = null; + + sutProvider.GetDependency() + .UserId + .Returns(userId); + + sutProvider.GetDependency() + .OrganizationOwner(orgId) + .Returns(true); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) + .Returns(true); + + sutProvider.GetDependency() + .SaveAsync(Arg.Any()) + .Returns(policy); + + // Act + var result = await sutProvider.Sut.PutVNext(orgId, model); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SaveAsync(Arg.Is( + m => m.PolicyUpdate.OrganizationId == orgId && + m.PolicyUpdate.Type == model.Policy.Type && + m.PolicyUpdate.Enabled == model.Policy.Enabled && + m.PerformedBy.UserId == userId && + m.PerformedBy.IsOrganizationOwnerOrProvider == true)); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .VNextSaveAsync(default); + + Assert.NotNull(result); + Assert.Equal(policy.Id, result.Id); + Assert.Equal(policy.Type, result.Type); + } + + [Theory] + [BitAutoData] + public async Task PutVNext_WhenPolicyValidatorsRefactorDisabled_UsesSavePolicyCommand( + SutProvider sutProvider, Guid orgId, + SavePolicyRequest model, Policy policy, Guid userId) + { + // Arrange + policy.Data = null; + + sutProvider.GetDependency() + .UserId + .Returns(userId); + + sutProvider.GetDependency() + .OrganizationOwner(orgId) + .Returns(true); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) + .Returns(false); + + sutProvider.GetDependency() + .VNextSaveAsync(Arg.Any()) + .Returns(policy); + + // Act + var result = await sutProvider.Sut.PutVNext(orgId, model); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .VNextSaveAsync(Arg.Is( + m => m.PolicyUpdate.OrganizationId == orgId && + m.PolicyUpdate.Type == model.Policy.Type && + m.PolicyUpdate.Enabled == model.Policy.Enabled && + m.PerformedBy.UserId == userId && + m.PerformedBy.IsOrganizationOwnerOrProvider == true)); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SaveAsync(default); + + Assert.NotNull(result); + Assert.Equal(policy.Id, result.Id); + Assert.Equal(policy.Type, result.Type); + } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs index b0774927e3..3f0443d31b 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -191,6 +192,37 @@ public class VerifyOrganizationDomainCommandTests x.PerformedBy.UserId == userId)); } + [Theory, BitAutoData] + public async Task UserVerifyOrganizationDomainAsync_WhenPolicyValidatorsRefactorFlagEnabled_UsesVNextSavePolicyCommand( + OrganizationDomain domain, Guid userId, SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetClaimedDomainsByDomainNameAsync(domain.DomainName) + .Returns([]); + + sutProvider.GetDependency() + .ResolveAsync(domain.DomainName, domain.Txt) + .Returns(true); + + sutProvider.GetDependency() + .UserId.Returns(userId); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) + .Returns(true); + + _ = await sutProvider.Sut.UserVerifyOrganizationDomainAsync(domain); + + await sutProvider.GetDependency() + .Received(1) + .SaveAsync(Arg.Is(m => + m.PolicyUpdate.Type == PolicyType.SingleOrg && + m.PolicyUpdate.OrganizationId == domain.OrganizationId && + m.PolicyUpdate.Enabled && + m.PerformedBy is StandardUser && + m.PerformedBy.UserId == userId)); + } + [Theory, BitAutoData] public async Task UserVerifyOrganizationDomainAsync_WhenDomainIsNotVerified_ThenSingleOrgPolicyShouldNotBeEnabled( OrganizationDomain domain, SutProvider sutProvider) diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/FreeFamiliesForEnterprisePolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/FreeFamiliesForEnterprisePolicyValidatorTests.cs index 8f8fd939fe..525169a1fb 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/FreeFamiliesForEnterprisePolicyValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/FreeFamiliesForEnterprisePolicyValidatorTests.cs @@ -92,7 +92,7 @@ public class FreeFamiliesForEnterprisePolicyValidatorTests .GetManyBySponsoringOrganizationAsync(policyUpdate.OrganizationId) .Returns(organizationSponsorships); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); await sutProvider.Sut.ExecutePreUpsertSideEffectAsync(savePolicyModel, policy); @@ -120,7 +120,7 @@ public class FreeFamiliesForEnterprisePolicyValidatorTests .GetManyBySponsoringOrganizationAsync(policyUpdate.OrganizationId) .Returns(organizationSponsorships); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); await sutProvider.Sut.ExecutePreUpsertSideEffectAsync(savePolicyModel, policy); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs index a65290e6a7..e6677c8a23 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs @@ -32,7 +32,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) .Returns(false); - var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); + var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); // Act await sutProvider.Sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState); @@ -58,7 +58,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) .Returns(true); - var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); + var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); // Act await sutProvider.Sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState); @@ -84,7 +84,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) .Returns(true); - var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); + var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); // Act await sutProvider.Sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState); @@ -110,7 +110,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests var collectionRepository = Substitute.For(); var sut = ArrangeSut(factory, policyRepository, collectionRepository); - var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); + var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); // Act await sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState); @@ -199,7 +199,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests var collectionRepository = Substitute.For(); var sut = ArrangeSut(factory, policyRepository, collectionRepository); - var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); + var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); // Act await sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState); @@ -238,7 +238,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) .Returns(true); - var policyRequest = new SavePolicyModel(policyUpdate, null, metadata); + var policyRequest = new SavePolicyModel(policyUpdate, metadata); // Act await sutProvider.Sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState); @@ -286,7 +286,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) .Returns(false); - var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); + var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); // Act await sutProvider.Sut.ExecutePostUpsertSideEffectAsync(policyRequest, postUpdatedPolicy, previousPolicyState); @@ -312,7 +312,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) .Returns(true); - var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); + var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); // Act await sutProvider.Sut.ExecutePostUpsertSideEffectAsync(policyRequest, postUpdatedPolicy, previousPolicyState); @@ -338,7 +338,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) .Returns(true); - var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); + var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); // Act await sutProvider.Sut.ExecutePostUpsertSideEffectAsync(policyRequest, postUpdatedPolicy, previousPolicyState); @@ -364,7 +364,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests var collectionRepository = Substitute.For(); var sut = ArrangeSut(factory, policyRepository, collectionRepository); - var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); + var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); // Act await sut.ExecutePostUpsertSideEffectAsync(policyRequest, postUpdatedPolicy, previousPolicyState); @@ -404,7 +404,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests var collectionRepository = Substitute.For(); var sut = ArrangeSut(factory, policyRepository, collectionRepository); - var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); + var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); // Act await sut.ExecutePostUpsertSideEffectAsync(policyRequest, postUpdatedPolicy, previousPolicyState); @@ -436,7 +436,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) .Returns(true); - var policyRequest = new SavePolicyModel(policyUpdate, null, metadata); + var policyRequest = new SavePolicyModel(policyUpdate, metadata); // Act await sutProvider.Sut.ExecutePostUpsertSideEffectAsync(policyRequest, postUpdatedPolicy, previousPolicyState); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/RequireSsoPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/RequireSsoPolicyValidatorTests.cs index 857aa5e09e..6fc6b85668 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/RequireSsoPolicyValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/RequireSsoPolicyValidatorTests.cs @@ -88,7 +88,7 @@ public class RequireSsoPolicyValidatorTests .GetByOrganizationIdAsync(policyUpdate.OrganizationId) .Returns(ssoConfig); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, policy); Assert.Contains("Key Connector is enabled", result, StringComparison.OrdinalIgnoreCase); @@ -109,7 +109,7 @@ public class RequireSsoPolicyValidatorTests .GetByOrganizationIdAsync(policyUpdate.OrganizationId) .Returns(ssoConfig); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, policy); Assert.Contains("Trusted device encryption is on", result, StringComparison.OrdinalIgnoreCase); @@ -129,7 +129,7 @@ public class RequireSsoPolicyValidatorTests .GetByOrganizationIdAsync(policyUpdate.OrganizationId) .Returns(ssoConfig); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, policy); Assert.True(string.IsNullOrEmpty(result)); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/ResetPasswordPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/ResetPasswordPolicyValidatorTests.cs index cdfd549454..b3d328c5ab 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/ResetPasswordPolicyValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/ResetPasswordPolicyValidatorTests.cs @@ -94,7 +94,7 @@ public class ResetPasswordPolicyValidatorTests .GetByOrganizationIdAsync(policyUpdate.OrganizationId) .Returns(ssoConfig); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, policy); Assert.Contains("Trusted device encryption is on and requires this policy.", result, StringComparison.OrdinalIgnoreCase); @@ -118,7 +118,7 @@ public class ResetPasswordPolicyValidatorTests .GetByOrganizationIdAsync(policyUpdate.OrganizationId) .Returns(ssoConfig); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, policy); Assert.True(string.IsNullOrEmpty(result)); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidatorTests.cs index cea464c155..7c58d46636 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidatorTests.cs @@ -162,7 +162,7 @@ public class SingleOrgPolicyValidatorTests .GetByOrganizationIdAsync(policyUpdate.OrganizationId) .Returns(ssoConfig); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, policy); Assert.Contains("Key Connector is enabled", result, StringComparison.OrdinalIgnoreCase); @@ -186,7 +186,7 @@ public class SingleOrgPolicyValidatorTests .HasVerifiedDomainsAsync(policyUpdate.OrganizationId) .Returns(false); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, policy); Assert.True(string.IsNullOrEmpty(result)); @@ -256,7 +256,7 @@ public class SingleOrgPolicyValidatorTests .RevokeNonCompliantOrganizationUsersAsync(Arg.Any()) .Returns(new CommandResult()); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); await sutProvider.Sut.ExecutePreUpsertSideEffectAsync(savePolicyModel, policy); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidatorTests.cs index 9eadbcc3b8..7d5aaf8d21 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidatorTests.cs @@ -169,7 +169,7 @@ public class TwoFactorAuthenticationPolicyValidatorTests (orgUserDetailUserWithout2Fa, false), }); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.ExecutePreUpsertSideEffectAsync(savePolicyModel, policy)); @@ -228,7 +228,7 @@ public class TwoFactorAuthenticationPolicyValidatorTests .RevokeNonCompliantOrganizationUsersAsync(Arg.Any()) .Returns(new CommandResult()); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); // Act await sutProvider.Sut.ExecutePreUpsertSideEffectAsync(savePolicyModel, policy); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/SavePolicyCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/SavePolicyCommandTests.cs index 6b85760794..b1e3faf257 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/SavePolicyCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/SavePolicyCommandTests.cs @@ -288,7 +288,7 @@ public class SavePolicyCommandTests { // Arrange var sutProvider = SutProviderFactory(); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); currentPolicy.OrganizationId = policyUpdate.OrganizationId; sutProvider.GetDependency() @@ -332,7 +332,7 @@ public class SavePolicyCommandTests var sutProvider = SutProviderFactory(); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); sutProvider.GetDependency() .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, policyUpdate.Type) diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/VNextSavePolicyCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/VNextSavePolicyCommandTests.cs index da10ea300f..a7dc0402a2 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/VNextSavePolicyCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/VNextSavePolicyCommandTests.cs @@ -33,7 +33,7 @@ public class VNextSavePolicyCommandTests fakePolicyValidationEvent ]); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); var newPolicy = new Policy { @@ -77,7 +77,7 @@ public class VNextSavePolicyCommandTests fakePolicyValidationEvent ]); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); currentPolicy.OrganizationId = policyUpdate.OrganizationId; sutProvider.GetDependency() @@ -117,7 +117,7 @@ public class VNextSavePolicyCommandTests { // Arrange var sutProvider = SutProviderFactory(); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); sutProvider.GetDependency() .GetOrganizationAbilityAsync(policyUpdate.OrganizationId) @@ -137,7 +137,7 @@ public class VNextSavePolicyCommandTests { // Arrange var sutProvider = SutProviderFactory(); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); sutProvider.GetDependency() .GetOrganizationAbilityAsync(policyUpdate.OrganizationId) @@ -167,7 +167,7 @@ public class VNextSavePolicyCommandTests new FakeSingleOrgDependencyEvent() ]); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); var requireSsoPolicy = new Policy { @@ -202,7 +202,7 @@ public class VNextSavePolicyCommandTests new FakeSingleOrgDependencyEvent() ]); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); var requireSsoPolicy = new Policy { @@ -237,7 +237,7 @@ public class VNextSavePolicyCommandTests new FakeSingleOrgDependencyEvent() ]); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); var requireSsoPolicy = new Policy { @@ -271,7 +271,7 @@ public class VNextSavePolicyCommandTests new FakeSingleOrgDependencyEvent() ]); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); ArrangeOrganization(sutProvider, policyUpdate); sutProvider.GetDependency() @@ -302,7 +302,7 @@ public class VNextSavePolicyCommandTests new FakeVaultTimeoutDependencyEvent() ]); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); ArrangeOrganization(sutProvider, policyUpdate); sutProvider.GetDependency() @@ -331,7 +331,7 @@ public class VNextSavePolicyCommandTests new FakeSingleOrgDependencyEvent() ]); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); ArrangeOrganization(sutProvider, policyUpdate); sutProvider.GetDependency() @@ -356,7 +356,7 @@ public class VNextSavePolicyCommandTests fakePolicyValidationEvent ]); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); var singleOrgPolicy = new Policy { diff --git a/test/Core.Test/Auth/Services/SsoConfigServiceTests.cs b/test/Core.Test/Auth/Services/SsoConfigServiceTests.cs index 7beb772b95..7319df17aa 100644 --- a/test/Core.Test/Auth/Services/SsoConfigServiceTests.cs +++ b/test/Core.Test/Auth/Services/SsoConfigServiceTests.cs @@ -1,8 +1,10 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; @@ -12,6 +14,7 @@ using Bit.Core.Auth.Services; using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -364,4 +367,54 @@ public class SsoConfigServiceTests await sutProvider.GetDependency().ReceivedWithAnyArgs() .UpsertAsync(default); } + + [Theory, BitAutoData] + public async Task SaveAsync_Tde_WhenPolicyValidatorsRefactorEnabled_UsesVNextSavePolicyCommand( + SutProvider sutProvider, Organization organization) + { + var ssoConfig = new SsoConfig + { + Id = default, + Data = new SsoConfigurationData + { + MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption, + }.Serialize(), + Enabled = true, + OrganizationId = organization.Id, + }; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) + .Returns(true); + + await sutProvider.Sut.SaveAsync(ssoConfig, organization); + + await sutProvider.GetDependency() + .Received(1) + .SaveAsync(Arg.Is(m => + m.PolicyUpdate.Type == PolicyType.SingleOrg && + m.PolicyUpdate.OrganizationId == organization.Id && + m.PolicyUpdate.Enabled && + m.PerformedBy is SystemUser)); + + await sutProvider.GetDependency() + .Received(1) + .SaveAsync(Arg.Is(m => + m.PolicyUpdate.Type == PolicyType.ResetPassword && + m.PolicyUpdate.GetDataModel().AutoEnrollEnabled && + m.PolicyUpdate.OrganizationId == organization.Id && + m.PolicyUpdate.Enabled && + m.PerformedBy is SystemUser)); + + await sutProvider.GetDependency() + .Received(1) + .SaveAsync(Arg.Is(m => + m.PolicyUpdate.Type == PolicyType.RequireSso && + m.PolicyUpdate.OrganizationId == organization.Id && + m.PolicyUpdate.Enabled && + m.PerformedBy is SystemUser)); + + await sutProvider.GetDependency().ReceivedWithAnyArgs() + .UpsertAsync(default); + } } From a1be1ae40b7023126dcce7f89afb925149ea58e8 Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Thu, 6 Nov 2025 14:44:44 +0100 Subject: [PATCH 02/77] Group sdk-internal dep (#6530) * Disable renovate for updates to internal sdk-internal * Group instead * Add trailing comma --- .github/renovate.json5 | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 5cf7aa29aa..bc377ed46c 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -41,6 +41,10 @@ matchUpdateTypes: ["patch"], dependencyDashboardApproval: false, }, + { + matchSourceUrls: ["https://github.com/bitwarden/sdk-internal"], + groupName: "sdk-internal", + }, { matchManagers: ["dockerfile", "docker-compose"], commitMessagePrefix: "[deps] BRE:", From 087c6915e72ea08db6a20f1a0639cca6db1b972d Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Thu, 6 Nov 2025 08:28:13 -0600 Subject: [PATCH 03/77] when ciphers are soft deleted, complete any associated security tasks (#6492) --- .../Repositories/ISecurityTaskRepository.cs | 6 + .../Services/Implementations/CipherService.cs | 6 + .../Repositories/SecurityTaskRepository.cs | 15 +++ .../Repositories/SecurityTaskRepository.cs | 20 ++++ .../SecurityTask_MarkCompleteByCipherIds.sql | 15 +++ .../Vault/Services/CipherServiceTests.cs | 57 ++++++++++ .../SecurityTaskRepositoryTests.cs | 106 ++++++++++++++++++ ...-23_00_CompleteSecurityTaskByCipherIds.sql | 15 +++ 8 files changed, 240 insertions(+) create mode 100644 src/Sql/dbo/Vault/Stored Procedures/SecurityTask/SecurityTask_MarkCompleteByCipherIds.sql create mode 100644 util/Migrator/DbScripts/2025-10-23_00_CompleteSecurityTaskByCipherIds.sql diff --git a/src/Core/Vault/Repositories/ISecurityTaskRepository.cs b/src/Core/Vault/Repositories/ISecurityTaskRepository.cs index 4b88f1c0e8..0be3bbd545 100644 --- a/src/Core/Vault/Repositories/ISecurityTaskRepository.cs +++ b/src/Core/Vault/Repositories/ISecurityTaskRepository.cs @@ -35,4 +35,10 @@ public interface ISecurityTaskRepository : IRepository /// The id of the organization /// A collection of security task metrics Task GetTaskMetricsAsync(Guid organizationId); + + /// + /// Marks all tasks associated with the respective ciphers as complete. + /// + /// Collection of cipher IDs + Task MarkAsCompleteByCipherIds(IEnumerable cipherIds); } diff --git a/src/Core/Vault/Services/Implementations/CipherService.cs b/src/Core/Vault/Services/Implementations/CipherService.cs index db458a523d..4e980f66b6 100644 --- a/src/Core/Vault/Services/Implementations/CipherService.cs +++ b/src/Core/Vault/Services/Implementations/CipherService.cs @@ -33,6 +33,7 @@ public class CipherService : ICipherService private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly ICollectionCipherRepository _collectionCipherRepository; + private readonly ISecurityTaskRepository _securityTaskRepository; private readonly IPushNotificationService _pushService; private readonly IAttachmentStorageService _attachmentStorageService; private readonly IEventService _eventService; @@ -53,6 +54,7 @@ public class CipherService : ICipherService IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, ICollectionCipherRepository collectionCipherRepository, + ISecurityTaskRepository securityTaskRepository, IPushNotificationService pushService, IAttachmentStorageService attachmentStorageService, IEventService eventService, @@ -71,6 +73,7 @@ public class CipherService : ICipherService _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; _collectionCipherRepository = collectionCipherRepository; + _securityTaskRepository = securityTaskRepository; _pushService = pushService; _attachmentStorageService = attachmentStorageService; _eventService = eventService; @@ -724,6 +727,7 @@ public class CipherService : ICipherService cipherDetails.ArchivedDate = null; } + await _securityTaskRepository.MarkAsCompleteByCipherIds([cipherDetails.Id]); await _cipherRepository.UpsertAsync(cipherDetails); await _eventService.LogCipherEventAsync(cipherDetails, EventType.Cipher_SoftDeleted); @@ -750,6 +754,8 @@ public class CipherService : ICipherService await _cipherRepository.SoftDeleteAsync(deletingCiphers.Select(c => c.Id), deletingUserId); } + await _securityTaskRepository.MarkAsCompleteByCipherIds(deletingCiphers.Select(c => c.Id)); + var events = deletingCiphers.Select(c => new Tuple(c, EventType.Cipher_SoftDeleted, null)); foreach (var eventsBatch in events.Chunk(100)) diff --git a/src/Infrastructure.Dapper/Vault/Repositories/SecurityTaskRepository.cs b/src/Infrastructure.Dapper/Vault/Repositories/SecurityTaskRepository.cs index 292e99d6ad..869321f280 100644 --- a/src/Infrastructure.Dapper/Vault/Repositories/SecurityTaskRepository.cs +++ b/src/Infrastructure.Dapper/Vault/Repositories/SecurityTaskRepository.cs @@ -85,4 +85,19 @@ public class SecurityTaskRepository : Repository, ISecurityT return tasksList; } + + /// + public async Task MarkAsCompleteByCipherIds(IEnumerable cipherIds) + { + if (!cipherIds.Any()) + { + return; + } + + await using var connection = new SqlConnection(ConnectionString); + await connection.ExecuteAsync( + $"[{Schema}].[SecurityTask_MarkCompleteByCipherIds]", + new { CipherIds = cipherIds.ToGuidIdArrayTVP() }, + commandType: CommandType.StoredProcedure); + } } diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/SecurityTaskRepository.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/SecurityTaskRepository.cs index d4f9424d40..9967f18a3e 100644 --- a/src/Infrastructure.EntityFramework/Vault/Repositories/SecurityTaskRepository.cs +++ b/src/Infrastructure.EntityFramework/Vault/Repositories/SecurityTaskRepository.cs @@ -96,4 +96,24 @@ public class SecurityTaskRepository : Repository + public async Task MarkAsCompleteByCipherIds(IEnumerable cipherIds) + { + if (!cipherIds.Any()) + { + return; + } + + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + + var cipherIdsList = cipherIds.ToList(); + + await dbContext.SecurityTasks + .Where(st => st.CipherId.HasValue && cipherIdsList.Contains(st.CipherId.Value) && st.Status != SecurityTaskStatus.Completed) + .ExecuteUpdateAsync(st => st + .SetProperty(s => s.Status, SecurityTaskStatus.Completed) + .SetProperty(s => s.RevisionDate, DateTime.UtcNow)); + } } diff --git a/src/Sql/dbo/Vault/Stored Procedures/SecurityTask/SecurityTask_MarkCompleteByCipherIds.sql b/src/Sql/dbo/Vault/Stored Procedures/SecurityTask/SecurityTask_MarkCompleteByCipherIds.sql new file mode 100644 index 0000000000..8e00d06e43 --- /dev/null +++ b/src/Sql/dbo/Vault/Stored Procedures/SecurityTask/SecurityTask_MarkCompleteByCipherIds.sql @@ -0,0 +1,15 @@ +CREATE PROCEDURE [dbo].[SecurityTask_MarkCompleteByCipherIds] + @CipherIds AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[SecurityTask] + SET + [Status] = 1, -- completed + [RevisionDate] = SYSUTCDATETIME() + WHERE + [CipherId] IN (SELECT [Id] FROM @CipherIds) + AND [Status] <> 1 -- Not already completed +END diff --git a/test/Core.Test/Vault/Services/CipherServiceTests.cs b/test/Core.Test/Vault/Services/CipherServiceTests.cs index 95391f1f44..fb53c41bad 100644 --- a/test/Core.Test/Vault/Services/CipherServiceTests.cs +++ b/test/Core.Test/Vault/Services/CipherServiceTests.cs @@ -2286,6 +2286,63 @@ public class CipherServiceTests .PushSyncCiphersAsync(deletingUserId); } + [Theory] + [BitAutoData] + public async Task SoftDeleteAsync_CallsMarkAsCompleteByCipherIds( + Guid deletingUserId, CipherDetails cipherDetails, SutProvider sutProvider) + { + cipherDetails.UserId = deletingUserId; + cipherDetails.OrganizationId = null; + cipherDetails.DeletedDate = null; + + sutProvider.GetDependency() + .GetUserByIdAsync(deletingUserId) + .Returns(new User + { + Id = deletingUserId, + }); + + await sutProvider.Sut.SoftDeleteAsync(cipherDetails, deletingUserId); + + await sutProvider.GetDependency() + .Received(1) + .MarkAsCompleteByCipherIds(Arg.Is>(ids => + ids.Count() == 1 && ids.First() == cipherDetails.Id)); + } + + [Theory] + [BitAutoData] + public async Task SoftDeleteManyAsync_CallsMarkAsCompleteByCipherIds( + Guid deletingUserId, List ciphers, SutProvider sutProvider) + { + var cipherIds = ciphers.Select(c => c.Id).ToArray(); + + foreach (var cipher in ciphers) + { + cipher.UserId = deletingUserId; + cipher.OrganizationId = null; + cipher.Edit = true; + cipher.DeletedDate = null; + } + + sutProvider.GetDependency() + .GetUserByIdAsync(deletingUserId) + .Returns(new User + { + Id = deletingUserId, + }); + sutProvider.GetDependency() + .GetManyByUserIdAsync(deletingUserId) + .Returns(ciphers); + + await sutProvider.Sut.SoftDeleteManyAsync(cipherIds, deletingUserId, null, false); + + await sutProvider.GetDependency() + .Received(1) + .MarkAsCompleteByCipherIds(Arg.Is>(ids => + ids.Count() == cipherIds.Length && ids.All(id => cipherIds.Contains(id)))); + } + private async Task AssertNoActionsAsync(SutProvider sutProvider) { await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetManyOrganizationDetailsByOrganizationIdAsync(default); diff --git a/test/Infrastructure.IntegrationTest/Vault/Repositories/SecurityTaskRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Vault/Repositories/SecurityTaskRepositoryTests.cs index f17950c04d..68c1be69f6 100644 --- a/test/Infrastructure.IntegrationTest/Vault/Repositories/SecurityTaskRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Vault/Repositories/SecurityTaskRepositoryTests.cs @@ -345,4 +345,110 @@ public class SecurityTaskRepositoryTests Assert.Equal(0, metrics.CompletedTasks); Assert.Equal(0, metrics.TotalTasks); } + + [DatabaseTheory, DatabaseData] + public async Task MarkAsCompleteByCipherIds_MarksPendingTasksAsCompleted( + IOrganizationRepository organizationRepository, + ICipherRepository cipherRepository, + ISecurityTaskRepository securityTaskRepository) + { + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + PlanType = PlanType.EnterpriseAnnually, + Plan = "Test Plan", + BillingEmail = "billing@email.com" + }); + + var cipher1 = await cipherRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + OrganizationId = organization.Id, + Data = "", + }); + + var cipher2 = await cipherRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + OrganizationId = organization.Id, + Data = "", + }); + + var task1 = await securityTaskRepository.CreateAsync(new SecurityTask + { + OrganizationId = organization.Id, + CipherId = cipher1.Id, + Status = SecurityTaskStatus.Pending, + Type = SecurityTaskType.UpdateAtRiskCredential, + }); + + var task2 = await securityTaskRepository.CreateAsync(new SecurityTask + { + OrganizationId = organization.Id, + CipherId = cipher2.Id, + Status = SecurityTaskStatus.Pending, + Type = SecurityTaskType.UpdateAtRiskCredential, + }); + + await securityTaskRepository.MarkAsCompleteByCipherIds([cipher1.Id, cipher2.Id]); + + var updatedTask1 = await securityTaskRepository.GetByIdAsync(task1.Id); + var updatedTask2 = await securityTaskRepository.GetByIdAsync(task2.Id); + + Assert.Equal(SecurityTaskStatus.Completed, updatedTask1.Status); + Assert.Equal(SecurityTaskStatus.Completed, updatedTask2.Status); + } + + [DatabaseTheory, DatabaseData] + public async Task MarkAsCompleteByCipherIds_OnlyUpdatesSpecifiedCiphers( + IOrganizationRepository organizationRepository, + ICipherRepository cipherRepository, + ISecurityTaskRepository securityTaskRepository) + { + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + PlanType = PlanType.EnterpriseAnnually, + Plan = "Test Plan", + BillingEmail = "billing@email.com" + }); + + var cipher1 = await cipherRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + OrganizationId = organization.Id, + Data = "", + }); + + var cipher2 = await cipherRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + OrganizationId = organization.Id, + Data = "", + }); + + var taskToUpdate = await securityTaskRepository.CreateAsync(new SecurityTask + { + OrganizationId = organization.Id, + CipherId = cipher1.Id, + Status = SecurityTaskStatus.Pending, + Type = SecurityTaskType.UpdateAtRiskCredential, + }); + + var taskToKeep = await securityTaskRepository.CreateAsync(new SecurityTask + { + OrganizationId = organization.Id, + CipherId = cipher2.Id, + Status = SecurityTaskStatus.Pending, + Type = SecurityTaskType.UpdateAtRiskCredential, + }); + + await securityTaskRepository.MarkAsCompleteByCipherIds([cipher1.Id]); + + var updatedTask = await securityTaskRepository.GetByIdAsync(taskToUpdate.Id); + var unchangedTask = await securityTaskRepository.GetByIdAsync(taskToKeep.Id); + + Assert.Equal(SecurityTaskStatus.Completed, updatedTask.Status); + Assert.Equal(SecurityTaskStatus.Pending, unchangedTask.Status); + } } diff --git a/util/Migrator/DbScripts/2025-10-23_00_CompleteSecurityTaskByCipherIds.sql b/util/Migrator/DbScripts/2025-10-23_00_CompleteSecurityTaskByCipherIds.sql new file mode 100644 index 0000000000..e465b8470a --- /dev/null +++ b/util/Migrator/DbScripts/2025-10-23_00_CompleteSecurityTaskByCipherIds.sql @@ -0,0 +1,15 @@ +CREATE OR ALTER PROCEDURE [dbo].[SecurityTask_MarkCompleteByCipherIds] + @CipherIds AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[SecurityTask] + SET + [Status] = 1, -- Completed + [RevisionDate] = SYSUTCDATETIME() + WHERE + [CipherId] IN (SELECT [Id] FROM @CipherIds) + AND [Status] <> 1 -- Not already completed +END From 5dbce33f749ca33ed31dbabcf0ae664b800f705e Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Thu, 6 Nov 2025 13:21:29 -0500 Subject: [PATCH 04/77] [PM-24273] Milestone 2C (#6544) * feat(billing): add mjml template and updated templates * feat(billing): update maileservices * feat(billing): add milestone2 discount * feat(billing): add milestone 2 updates and stripe constants * tests(billing): add handler tests * fix(billing): update mailer view and templates * fix(billing): revert mailservice changes * fix(billing): swap mailer service in handler * test(billing): update handler tests --- .../Implementations/UpcomingInvoiceHandler.cs | 81 +- src/Core/Billing/Constants/StripeConstants.cs | 1 + .../Mjml/emails/invoice-upcoming.mjml | 27 + .../UpdatedInvoiceUpcomingView.cs | 10 + .../UpdatedInvoiceUpcomingView.html.hbs | 30 + .../UpdatedInvoiceUpcomingView.text.hbs | 3 + .../Services/UpcomingInvoiceHandlerTests.cs | 947 ++++++++++++++++++ 7 files changed, 1089 insertions(+), 10 deletions(-) create mode 100644 src/Core/MailTemplates/Mjml/emails/invoice-upcoming.mjml create mode 100644 src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.cs create mode 100644 src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.html.hbs create mode 100644 src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.text.hbs create mode 100644 test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index 4260d67dfa..f24229f151 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -2,18 +2,22 @@ #nullable disable +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; 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.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; +using Bit.Core.Platform.Mail.Mailer; using Bit.Core.Repositories; using Bit.Core.Services; using Stripe; +using static Bit.Core.Billing.Constants.StripeConstants; using Event = Stripe.Event; namespace Bit.Billing.Services.Implementations; @@ -29,7 +33,9 @@ public class UpcomingInvoiceHandler( IStripeEventService stripeEventService, IStripeEventUtilityService stripeEventUtilityService, IUserRepository userRepository, - IValidateSponsorshipCommand validateSponsorshipCommand) + IValidateSponsorshipCommand validateSponsorshipCommand, + IMailer mailer, + IFeatureService featureService) : IUpcomingInvoiceHandler { public async Task HandleAsync(Event parsedEvent) @@ -37,7 +43,8 @@ public class UpcomingInvoiceHandler( var invoice = await stripeEventService.GetInvoice(parsedEvent); var customer = - await stripeFacade.GetCustomer(invoice.CustomerId, new CustomerGetOptions { Expand = ["subscriptions", "tax", "tax_ids"] }); + await stripeFacade.GetCustomer(invoice.CustomerId, + new CustomerGetOptions { Expand = ["subscriptions", "tax", "tax_ids"] }); var subscription = customer.Subscriptions.FirstOrDefault(); @@ -68,7 +75,8 @@ public class UpcomingInvoiceHandler( if (stripeEventUtilityService.IsSponsoredSubscription(subscription)) { - var sponsorshipIsValid = await validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId.Value); + var sponsorshipIsValid = + await validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId.Value); if (!sponsorshipIsValid) { @@ -122,9 +130,17 @@ public class UpcomingInvoiceHandler( } } + var milestone2Feature = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2); + if (milestone2Feature) + { + await UpdateSubscriptionItemPriceIdAsync(parsedEvent, subscription, user); + } + if (user.Premium) { - await SendUpcomingInvoiceEmailsAsync(new List { user.Email }, invoice); + await (milestone2Feature + ? SendUpdatedUpcomingInvoiceEmailsAsync(new List { user.Email }) + : SendUpcomingInvoiceEmailsAsync(new List { user.Email }, invoice)); } } else if (providerId.HasValue) @@ -142,6 +158,39 @@ public class UpcomingInvoiceHandler( } } + private async Task UpdateSubscriptionItemPriceIdAsync(Event parsedEvent, Subscription subscription, User user) + { + var pricingItem = + subscription.Items.FirstOrDefault(i => i.Price.Id == Prices.PremiumAnnually); + if (pricingItem != null) + { + try + { + var plan = await pricingClient.GetAvailablePremiumPlan(); + await stripeFacade.UpdateSubscription(subscription.Id, + new SubscriptionUpdateOptions + { + Items = + [ + new SubscriptionItemOptions { Id = pricingItem.Id, Price = plan.Seat.StripePriceId } + ], + Discounts = + [ + new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone2SubscriptionDiscount } + ] + }); + } + catch (Exception exception) + { + logger.LogError( + exception, + "Failed to update user's ({UserID}) subscription price id while processing event with ID {EventID}", + user.Id, + parsedEvent.Id); + } + } + } + private async Task SendUpcomingInvoiceEmailsAsync(IEnumerable emails, Invoice invoice) { var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); @@ -159,7 +208,19 @@ public class UpcomingInvoiceHandler( } } - private async Task SendProviderUpcomingInvoiceEmailsAsync(IEnumerable emails, Invoice invoice, Subscription subscription, Guid providerId) + private async Task SendUpdatedUpcomingInvoiceEmailsAsync(IEnumerable emails) + { + var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); + var updatedUpcomingEmail = new UpdatedInvoiceUpcomingMail + { + ToEmails = validEmails, + View = new UpdatedInvoiceUpcomingView() + }; + await mailer.SendEmail(updatedUpcomingEmail); + } + + private async Task SendProviderUpcomingInvoiceEmailsAsync(IEnumerable emails, Invoice invoice, + Subscription subscription, Guid providerId) { var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); @@ -205,12 +266,12 @@ public class UpcomingInvoiceHandler( organization.PlanType.GetProductTier() != ProductTierType.Families && customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates; - if (nonUSBusinessUse && customer.TaxExempt != StripeConstants.TaxExempt.Reverse) + if (nonUSBusinessUse && customer.TaxExempt != TaxExempt.Reverse) { try { await stripeFacade.UpdateCustomer(subscription.CustomerId, - new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse }); + new CustomerUpdateOptions { TaxExempt = TaxExempt.Reverse }); } catch (Exception exception) { @@ -250,12 +311,12 @@ public class UpcomingInvoiceHandler( string eventId) { if (customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates && - customer.TaxExempt != StripeConstants.TaxExempt.Reverse) + customer.TaxExempt != TaxExempt.Reverse) { try { await stripeFacade.UpdateCustomer(subscription.CustomerId, - new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse }); + new CustomerUpdateOptions { TaxExempt = TaxExempt.Reverse }); } catch (Exception exception) { diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index 131adfedf8..517273db4e 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -22,6 +22,7 @@ public static class StripeConstants { public const string LegacyMSPDiscount = "msp-discount-35"; public const string SecretsManagerStandalone = "sm-standalone"; + public const string Milestone2SubscriptionDiscount = "cm3nHfO1"; public static class MSPDiscounts { diff --git a/src/Core/MailTemplates/Mjml/emails/invoice-upcoming.mjml b/src/Core/MailTemplates/Mjml/emails/invoice-upcoming.mjml new file mode 100644 index 0000000000..c50a5d1292 --- /dev/null +++ b/src/Core/MailTemplates/Mjml/emails/invoice-upcoming.mjml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + 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/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.cs b/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.cs new file mode 100644 index 0000000000..aeca436dbb --- /dev/null +++ b/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.cs @@ -0,0 +1,10 @@ +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 new file mode 100644 index 0000000000..a044171fe5 --- /dev/null +++ b/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.html.hbs @@ -0,0 +1,30 @@ +
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 new file mode 100644 index 0000000000..a2db92bac2 --- /dev/null +++ b/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.text.hbs @@ -0,0 +1,3 @@ +{{#>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/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs new file mode 100644 index 0000000000..899df4ea53 --- /dev/null +++ b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs @@ -0,0 +1,947 @@ +using Bit.Billing.Services; +using Bit.Billing.Services.Implementations; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Models.StaticStore.Plans; +using Bit.Core.Billing.Payment.Models; +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.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; +using Bit.Core.Platform.Mail.Mailer; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Stripe; +using Xunit; +using static Bit.Core.Billing.Constants.StripeConstants; +using Address = Stripe.Address; +using Event = Stripe.Event; +using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan; + +namespace Bit.Billing.Test.Services; + +public class UpcomingInvoiceHandlerTests +{ + private readonly IGetPaymentMethodQuery _getPaymentMethodQuery; + private readonly ILogger _logger; + private readonly IMailService _mailService; + private readonly IOrganizationRepository _organizationRepository; + private readonly IPricingClient _pricingClient; + private readonly IProviderRepository _providerRepository; + private readonly IStripeFacade _stripeFacade; + private readonly IStripeEventService _stripeEventService; + private readonly IStripeEventUtilityService _stripeEventUtilityService; + private readonly IUserRepository _userRepository; + private readonly IValidateSponsorshipCommand _validateSponsorshipCommand; + private readonly IMailer _mailer; + private readonly IFeatureService _featureService; + + private readonly UpcomingInvoiceHandler _sut; + + private readonly Guid _userId = Guid.NewGuid(); + private readonly Guid _organizationId = Guid.NewGuid(); + private readonly Guid _providerId = Guid.NewGuid(); + + + public UpcomingInvoiceHandlerTests() + { + _getPaymentMethodQuery = Substitute.For(); + _logger = Substitute.For>(); + _mailService = Substitute.For(); + _organizationRepository = Substitute.For(); + _pricingClient = Substitute.For(); + _providerRepository = Substitute.For(); + _stripeFacade = Substitute.For(); + _stripeEventService = Substitute.For(); + _stripeEventUtilityService = Substitute.For(); + _userRepository = Substitute.For(); + _validateSponsorshipCommand = Substitute.For(); + _mailer = Substitute.For(); + _featureService = Substitute.For(); + + _sut = new UpcomingInvoiceHandler( + _getPaymentMethodQuery, + _logger, + _mailService, + _organizationRepository, + _pricingClient, + _providerRepository, + _stripeFacade, + _stripeEventService, + _stripeEventUtilityService, + _userRepository, + _validateSponsorshipCommand, + _mailer, + _featureService); + } + + [Fact] + public async Task HandleAsync_WhenNullSubscription_DoesNothing() + { + // Arrange + var parsedEvent = new Event(); + var invoice = new Invoice { CustomerId = "cus_123" }; + var customer = new Customer { Id = "cus_123", Subscriptions = new StripeList { Data = [] } }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade + .GetCustomer(invoice.CustomerId, Arg.Any()) + .Returns(customer); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _stripeFacade.DidNotReceive() + .UpdateCustomer(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task HandleAsync_WhenValidUser_SendsEmail() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var customerId = "cus_123"; + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 10000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = customerId, + Items = new StripeList + { + Data = new List + { + new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } } + } + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, + Customer = new Customer { Id = customerId }, + Metadata = new Dictionary() + }; + var user = new User { Id = _userId, Email = "user@example.com", Premium = true }; + var plan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, + Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } + }; + var customer = new Customer + { + Id = customerId, + Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported }, + Subscriptions = new StripeList { Data = [subscription] } + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade + .GetCustomer(customerId, Arg.Any()) + .Returns(customer); + + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(null, _userId, null)); + + _userRepository.GetByIdAsync(_userId).Returns(user); + _pricingClient.GetAvailablePremiumPlan().Returns(plan); + + // If milestone 2 is disabled, the default email is sent + _featureService + .IsEnabled(FeatureFlagKeys.PM23341_Milestone_2) + .Returns(false); + + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _userRepository.Received(1).GetByIdAsync(_userId); + + 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)); + } + + [Fact] + public async Task + HandleAsync_WhenUserValid_AndMilestone2Enabled_UpdatesPriceId_AndSendsUpdatedInvoiceUpcomingEmail() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var customerId = "cus_123"; + var priceSubscriptionId = "sub-1"; + var priceId = "price-id-2"; + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 10000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = customerId, + Items = new StripeList + { + Data = new List + { + new() { Id = priceSubscriptionId, Price = new Price { Id = Prices.PremiumAnnually } } + } + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, + Customer = new Customer + { + Id = customerId, + Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported } + }, + Metadata = new Dictionary() + }; + var user = new User { Id = _userId, Email = "user@example.com", Premium = true }; + var plan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Purchasable { Price = 10M, StripePriceId = priceId }, + Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } + }; + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = new List { subscription } } + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade + .GetCustomer(customerId, Arg.Any()) + .Returns(customer); + + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(null, _userId, null)); + + _userRepository.GetByIdAsync(_userId).Returns(user); + _pricingClient.GetAvailablePremiumPlan().Returns(plan); + _stripeFacade.UpdateSubscription( + subscription.Id, + Arg.Any()) + .Returns(subscription); + + // If milestone 2 is true, the updated invoice email is sent + _featureService + .IsEnabled(FeatureFlagKeys.PM23341_Milestone_2) + .Returns(true); + + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _userRepository.Received(1).GetByIdAsync(_userId); + await _pricingClient.Received(1).GetAvailablePremiumPlan(); + await _stripeFacade.Received(1).UpdateSubscription( + Arg.Is("sub_123"), + Arg.Is(o => + o.Items[0].Id == priceSubscriptionId && + o.Items[0].Price == priceId)); + + // Verify the updated invoice email was sent + await _mailer.Received(1).SendEmail( + Arg.Is(email => + email.ToEmails.Contains("user@example.com") && + email.Subject == "Your Subscription Will Renew Soon")); + } + + [Fact] + public async Task HandleAsync_WhenOrganizationHasSponsorship_SendsEmail() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var invoice = new Invoice + { + CustomerId = "cus_123", + AmountDue = 10000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = "cus_123", + Items = new StripeList(), + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, + Customer = new Customer { Id = "cus_123" }, + Metadata = new Dictionary(), + LatestInvoiceId = "inv_latest" + }; + var customer = new Customer + { + Id = "cus_123", + Subscriptions = new StripeList { Data = new List { subscription } }, + Address = new Address { Country = "US" } + }; + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.EnterpriseAnnually + }; + var plan = new FamiliesPlan(); + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade + .GetCustomer(invoice.CustomerId, Arg.Any()) + .Returns(customer); + + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + + _organizationRepository + .GetByIdAsync(_organizationId) + .Returns(organization); + + _pricingClient + .GetPlanOrThrow(organization.PlanType) + .Returns(plan); + + _stripeEventUtilityService + .IsSponsoredSubscription(subscription) + .Returns(true); + // Configure that this is a sponsored subscription + _stripeEventUtilityService + .IsSponsoredSubscription(subscription) + .Returns(true); + _validateSponsorshipCommand + .ValidateSponsorshipAsync(_organizationId) + .Returns(true); + + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _organizationRepository.Received(1).GetByIdAsync(_organizationId); + await _validateSponsorshipCommand.Received(1).ValidateSponsorshipAsync(_organizationId); + + 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)); + } + + [Fact] + public async Task + HandleAsync_WhenOrganizationHasSponsorship_ButInvalidSponsorship_RetrievesUpdatedInvoice_SendsEmail() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var invoice = new Invoice + { + CustomerId = "cus_123", + AmountDue = 10000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = "cus_123", + Items = new StripeList + { + Data = + [new SubscriptionItem { Price = new Price { Id = "2021-family-for-enterprise-annually" } }] + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, + Customer = new Customer { Id = "cus_123" }, + Metadata = new Dictionary(), + LatestInvoiceId = "inv_latest" + }; + var customer = new Customer + { + Id = "cus_123", + Subscriptions = new StripeList { Data = new List { subscription } }, + Address = new Address { Country = "US" } + }; + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.EnterpriseAnnually + }; + var plan = new FamiliesPlan(); + + var paymentMethod = new Card { Last4 = "4242", Brand = "visa" }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade + .GetCustomer(invoice.CustomerId, Arg.Any()) + .Returns(customer); + + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + + _organizationRepository + .GetByIdAsync(_organizationId) + .Returns(organization); + + _pricingClient + .GetPlanOrThrow(organization.PlanType) + .Returns(plan); + + // Configure that this is not a sponsored subscription + _stripeEventUtilityService + .IsSponsoredSubscription(subscription) + .Returns(true); + + // Validate sponsorship should return false + _validateSponsorshipCommand + .ValidateSponsorshipAsync(_organizationId) + .Returns(false); + _stripeFacade + .GetInvoice(subscription.LatestInvoiceId) + .Returns(invoice); + + _getPaymentMethodQuery.Run(organization).Returns(MaskedPaymentMethod.From(paymentMethod)); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _organizationRepository.Received(1).GetByIdAsync(_organizationId); + _stripeEventUtilityService.Received(1).IsSponsoredSubscription(subscription); + await _validateSponsorshipCommand.Received(1).ValidateSponsorshipAsync(_organizationId); + await _stripeFacade.Received(1).GetInvoice(Arg.Is("inv_latest")); + + 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)); + } + + [Fact] + public async Task HandleAsync_WhenValidOrganization_SendsEmail() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var invoice = new Invoice + { + CustomerId = "cus_123", + AmountDue = 10000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = "cus_123", + Items = new StripeList + { + Data = + [new SubscriptionItem { Price = new Price { Id = "enterprise-annually" } }] + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, + Customer = new Customer { Id = "cus_123" }, + Metadata = new Dictionary(), + LatestInvoiceId = "inv_latest" + }; + var customer = new Customer + { + Id = "cus_123", + Subscriptions = new StripeList { Data = new List { subscription } }, + Address = new Address { Country = "US" } + }; + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.EnterpriseAnnually + }; + var plan = new FamiliesPlan(); + + var paymentMethod = new Card { Last4 = "4242", Brand = "visa" }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade + .GetCustomer(invoice.CustomerId, Arg.Any()) + .Returns(customer); + + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + + _organizationRepository + .GetByIdAsync(_organizationId) + .Returns(organization); + + _pricingClient + .GetPlanOrThrow(organization.PlanType) + .Returns(plan); + + _stripeEventUtilityService + .IsSponsoredSubscription(subscription) + .Returns(false); + + _stripeFacade + .GetInvoice(subscription.LatestInvoiceId) + .Returns(invoice); + + _getPaymentMethodQuery.Run(organization).Returns(MaskedPaymentMethod.From(paymentMethod)); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _organizationRepository.Received(1).GetByIdAsync(_organizationId); + _stripeEventUtilityService.Received(1).IsSponsoredSubscription(subscription); + + // Should not validate sponsorship for non-sponsored subscription + await _validateSponsorshipCommand.DidNotReceive().ValidateSponsorshipAsync(Arg.Any()); + + 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)); + } + + + [Fact] + public async Task HandleAsync_WhenValidProviderSubscription_SendsEmail() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var invoice = new Invoice + { + CustomerId = "cus_123", + AmountDue = 10000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = "cus_123", + Items = new StripeList(), + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, + Customer = new Customer { Id = "cus_123" }, + Metadata = new Dictionary(), + CollectionMethod = "charge_automatically" + }; + var customer = new Customer + { + Id = "cus_123", + Subscriptions = new StripeList { Data = new List { subscription } }, + Address = new Address { Country = "UK" }, + TaxExempt = TaxExempt.None + }; + var provider = new Provider { Id = _providerId, BillingEmail = "provider@example.com" }; + + var paymentMethod = new Card { Last4 = "4242", Brand = "visa" }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade.GetCustomer(invoice.CustomerId, Arg.Any()).Returns(customer); + + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(null, null, _providerId)); + + _providerRepository.GetByIdAsync(_providerId).Returns(provider); + _getPaymentMethodQuery.Run(provider).Returns(MaskedPaymentMethod.From(paymentMethod)); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _providerRepository.Received(2).GetByIdAsync(_providerId); + + // Verify tax exempt was set to reverse for non-US providers + await _stripeFacade.Received(1).UpdateCustomer( + Arg.Is("cus_123"), + Arg.Is(o => o.TaxExempt == TaxExempt.Reverse)); + + // Verify automatic tax was enabled + await _stripeFacade.Received(1).UpdateSubscription( + Arg.Is("sub_123"), + Arg.Is(o => o.AutomaticTax.Enabled == true)); + + // Verify provider invoice email was sent + await _mailService.Received(1).SendProviderInvoiceUpcoming( + Arg.Is>(e => e.Contains("provider@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(s => s == subscription.CollectionMethod), + Arg.Is(b => b == true), + Arg.Is(s => s == $"{paymentMethod.Brand} ending in {paymentMethod.Last4}")); + } + + [Fact] + public async Task HandleAsync_WhenUpdateSubscriptionItemPriceIdFails_LogsErrorAndSendsEmail() + { + // Arrange + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var customerId = "cus_123"; + var priceSubscriptionId = "sub-1"; + var priceId = "price-id-2"; + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 10000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = customerId, + Items = new StripeList + { + Data = new List + { + new() { Id = priceSubscriptionId, Price = new Price { Id = Prices.PremiumAnnually } } + } + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Customer = new Customer + { + Id = customerId, + Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported } + }, + Metadata = new Dictionary() + }; + var user = new User { Id = _userId, Email = "user@example.com", Premium = true }; + var plan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Purchasable { Price = 10M, StripePriceId = priceId }, + Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } + }; + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = new List { subscription } } + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade.GetCustomer(invoice.CustomerId, Arg.Any()).Returns(customer); + + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(null, _userId, null)); + + _userRepository.GetByIdAsync(_userId).Returns(user); + + _featureService + .IsEnabled(FeatureFlagKeys.PM23341_Milestone_2) + .Returns(true); + + _pricingClient.GetAvailablePremiumPlan().Returns(plan); + + // Setup exception when updating subscription + _stripeFacade + .UpdateSubscription(Arg.Any(), Arg.Any()) + .ThrowsAsync(new Exception()); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + _logger.Received(1).Log( + LogLevel.Error, + Arg.Any(), + Arg.Is(o => + o.ToString() + .Contains( + $"Failed to update user's ({user.Id}) subscription price id while processing event with ID {parsedEvent.Id}")), + 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")); + } + + [Fact] + public async Task HandleAsync_WhenOrganizationNotFound_DoesNothing() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var invoice = new Invoice + { + CustomerId = "cus_123", + AmountDue = 10000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = "cus_123", + Items = new StripeList(), + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, + Customer = new Customer { Id = "cus_123" }, + Metadata = new Dictionary(), + }; + var customer = new Customer + { + Id = "cus_123", + Subscriptions = new StripeList { Data = new List { subscription } } + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade + .GetCustomer(invoice.CustomerId, Arg.Any()) + .Returns(customer); + + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + + // Organization not found + _organizationRepository.GetByIdAsync(_organizationId).Returns((Organization)null); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _organizationRepository.Received(1).GetByIdAsync(_organizationId); + + // Verify no emails were sent + await _mailService.DidNotReceive().SendInvoiceUpcoming( + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any()); + } + + [Fact] + public async Task HandleAsync_WhenZeroAmountInvoice_DoesNothing() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var invoice = new Invoice + { + CustomerId = "cus_123", + AmountDue = 0, // Zero amount due + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Free Item" } } + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = "cus_123", + Items = new StripeList(), + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, + Customer = new Customer { Id = "cus_123" }, + Metadata = new Dictionary() + }; + var user = new User { Id = _userId, Email = "user@example.com", Premium = true }; + var customer = new Customer + { + Id = "cus_123", + Subscriptions = new StripeList { Data = new List { subscription } } + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade + .GetCustomer(invoice.CustomerId, Arg.Any()) + .Returns(customer); + + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(null, _userId, null)); + + _userRepository.GetByIdAsync(_userId).Returns(user); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _userRepository.Received(1).GetByIdAsync(_userId); + + // Should not + await _mailService.DidNotReceive().SendInvoiceUpcoming( + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any()); + } + + [Fact] + public async Task HandleAsync_WhenUserNotFound_DoesNothing() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var invoice = new Invoice + { + CustomerId = "cus_123", + AmountDue = 10000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = "cus_123", + Items = new StripeList(), + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, + Customer = new Customer { Id = "cus_123" }, + Metadata = new Dictionary() + }; + var customer = new Customer + { + Id = "cus_123", + Subscriptions = new StripeList { Data = new List { subscription } } + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade + .GetCustomer(invoice.CustomerId, Arg.Any()) + .Returns(customer); + + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(null, _userId, null)); + + // User not found + _userRepository.GetByIdAsync(_userId).Returns((User)null); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _userRepository.Received(1).GetByIdAsync(_userId); + + // Verify no emails were sent + await _mailService.DidNotReceive().SendInvoiceUpcoming( + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any()); + + await _mailer.DidNotReceive().SendEmail(Arg.Any()); + } + + [Fact] + public async Task HandleAsync_WhenProviderNotFound_DoesNothing() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var invoice = new Invoice + { + CustomerId = "cus_123", + AmountDue = 10000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = "cus_123", + Items = new StripeList(), + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, + Customer = new Customer { Id = "cus_123" }, + Metadata = new Dictionary() + }; + var customer = new Customer + { + Id = "cus_123", + Subscriptions = new StripeList { Data = new List { subscription } } + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade + .GetCustomer(invoice.CustomerId, Arg.Any()) + .Returns(customer); + + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(null, null, _providerId)); + + // Provider not found + _providerRepository.GetByIdAsync(_providerId).Returns((Provider)null); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _providerRepository.Received(1).GetByIdAsync(_providerId); + + // Verify no provider emails were sent + await _mailService.DidNotReceive().SendProviderInvoiceUpcoming( + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } +} From 43d14971f597cdfe43987c6bfe4cbd5b85939b5b Mon Sep 17 00:00:00 2001 From: Patrick-Pimentel-Bitwarden Date: Thu, 6 Nov 2025 13:24:59 -0500 Subject: [PATCH 05/77] fix(prevent-bad-existing-sso-user): [PM-24579] Fix Prevent Existing Non Confirmed and Accepted SSO Users (#6529) * fix(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non Confirmed and Accepted SSO Users - Fixed bad code and added comments. * test(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non Confirmed and Accepted SSO Users - Added new test to make sure invited users aren't allowed through at the appropriate time. --- .../src/Sso/Controllers/AccountController.cs | 234 +++++++++++------- .../Controllers/AccountControllerTest.cs | 176 ++++++------- 2 files changed, 225 insertions(+), 185 deletions(-) diff --git a/bitwarden_license/src/Sso/Controllers/AccountController.cs b/bitwarden_license/src/Sso/Controllers/AccountController.cs index 35266d219b..a0842daa34 100644 --- a/bitwarden_license/src/Sso/Controllers/AccountController.cs +++ b/bitwarden_license/src/Sso/Controllers/AccountController.cs @@ -1,7 +1,4 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.Security.Claims; +using System.Security.Claims; using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; @@ -167,6 +164,8 @@ public class AccountController : Controller { var context = await _interaction.GetAuthorizationContextAsync(returnUrl); + // FIXME: Update this file to be null safe and then delete the line below +#nullable disable if (!context.Parameters.AllKeys.Contains("domain_hint") || string.IsNullOrWhiteSpace(context.Parameters["domain_hint"])) { @@ -182,6 +181,7 @@ public class AccountController : Controller var domainHint = context.Parameters["domain_hint"]; var organization = await _organizationRepository.GetByIdentifierAsync(domainHint); +#nullable restore if (organization == null) { @@ -263,30 +263,33 @@ public class AccountController : Controller // See if the user has logged in with this SSO provider before and has already been provisioned. // This is signified by the user existing in the User table and the SSOUser table for the SSO provider they're using. - var (user, provider, providerUserId, claims, ssoConfigData) = await FindUserFromExternalProviderAsync(result); + var (possibleSsoLinkedUser, provider, providerUserId, claims, ssoConfigData) = await FindUserFromExternalProviderAsync(result); // We will look these up as required (lazy resolution) to avoid multiple DB hits. - Organization organization = null; - OrganizationUser orgUser = null; + Organization? organization = null; + OrganizationUser? orgUser = null; // The user has not authenticated with this SSO provider before. // They could have an existing Bitwarden account in the User table though. - if (user == null) + if (possibleSsoLinkedUser == null) { + // FIXME: Update this file to be null safe and then delete the line below +#nullable disable // If we're manually linking to SSO, the user's external identifier will be passed as query string parameter. var userIdentifier = result.Properties.Items.Keys.Contains("user_identifier") ? result.Properties.Items["user_identifier"] : null; - var (provisionedUser, foundOrganization, foundOrCreatedOrgUser) = - await AutoProvisionUserAsync( + var (resolvedUser, foundOrganization, foundOrCreatedOrgUser) = + await CreateUserAndOrgUserConditionallyAsync( provider, providerUserId, claims, userIdentifier, ssoConfigData); +#nullable restore - user = provisionedUser; + possibleSsoLinkedUser = resolvedUser; if (preventOrgUserLoginIfStatusInvalid) { @@ -297,9 +300,10 @@ public class AccountController : Controller if (preventOrgUserLoginIfStatusInvalid) { - if (user == null) throw new Exception(_i18nService.T("UserShouldBeFound")); + User resolvedSsoLinkedUser = possibleSsoLinkedUser + ?? throw new Exception(_i18nService.T("UserShouldBeFound")); - await PreventOrgUserLoginIfStatusInvalidAsync(organization, provider, orgUser, user); + await PreventOrgUserLoginIfStatusInvalidAsync(organization, provider, orgUser, resolvedSsoLinkedUser); // This allows us to collect any additional claims or properties // for the specific protocols used and store them in the local auth cookie. @@ -314,19 +318,20 @@ public class AccountController : Controller // Issue authentication cookie for user await HttpContext.SignInAsync( - new IdentityServerUser(user.Id.ToString()) + new IdentityServerUser(resolvedSsoLinkedUser.Id.ToString()) { - DisplayName = user.Email, + DisplayName = resolvedSsoLinkedUser.Email, IdentityProvider = provider, AdditionalClaims = additionalLocalClaims.ToArray() }, localSignInProps); } else { + // PM-24579: remove this else block with feature flag removal. // Either the user already authenticated with the SSO provider, or we've just provisioned them. // Either way, we have associated the SSO login with a Bitwarden user. // We will now sign the Bitwarden user in. - if (user != null) + if (possibleSsoLinkedUser != null) { // This allows us to collect any additional claims or properties // for the specific protocols used and store them in the local auth cookie. @@ -341,9 +346,9 @@ public class AccountController : Controller // Issue authentication cookie for user await HttpContext.SignInAsync( - new IdentityServerUser(user.Id.ToString()) + new IdentityServerUser(possibleSsoLinkedUser.Id.ToString()) { - DisplayName = user.Email, + DisplayName = possibleSsoLinkedUser.Email, IdentityProvider = provider, AdditionalClaims = additionalLocalClaims.ToArray() }, localSignInProps); @@ -353,8 +358,11 @@ public class AccountController : Controller // Delete temporary cookie used during external authentication await HttpContext.SignOutAsync(AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme); + // FIXME: Update this file to be null safe and then delete the line below +#nullable disable // Retrieve return URL var returnUrl = result.Properties.Items["return_url"] ?? "~/"; +#nullable restore // Check if external login is in the context of an OIDC request var context = await _interaction.GetAuthorizationContextAsync(returnUrl); @@ -373,6 +381,8 @@ public class AccountController : Controller return Redirect(returnUrl); } + // FIXME: Update this file to be null safe and then delete the line below +#nullable disable [HttpGet] public async Task LogoutAsync(string logoutId) { @@ -407,15 +417,22 @@ public class AccountController : Controller return Redirect("~/"); } } +#nullable restore /// /// Attempts to map the external identity to a Bitwarden user, through the SsoUser table, which holds the `externalId`. /// The claims on the external identity are used to determine an `externalId`, and that is used to find the appropriate `SsoUser` and `User` records. /// - private async Task<(User user, string provider, string providerUserId, IEnumerable claims, - SsoConfigurationData config)> - FindUserFromExternalProviderAsync(AuthenticateResult result) + private async Task<( + User? possibleSsoUser, + string provider, + string providerUserId, + IEnumerable claims, + SsoConfigurationData config + )> FindUserFromExternalProviderAsync(AuthenticateResult result) { + // FIXME: Update this file to be null safe and then delete the line below +#nullable disable var provider = result.Properties.Items["scheme"]; var orgId = new Guid(provider); var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgId); @@ -458,6 +475,7 @@ public class AccountController : Controller externalUser.FindFirst("upn") ?? externalUser.FindFirst("eppn") ?? throw new Exception(_i18nService.T("UnknownUserId")); +#nullable restore // Remove the user id claim so we don't include it as an extra claim if/when we provision the user var claims = externalUser.Claims.ToList(); @@ -466,13 +484,15 @@ public class AccountController : Controller // find external user var providerUserId = userIdClaim.Value; - var user = await _userRepository.GetBySsoUserAsync(providerUserId, orgId); + var possibleSsoUser = await _userRepository.GetBySsoUserAsync(providerUserId, orgId); - return (user, provider, providerUserId, claims, ssoConfigData); + return (possibleSsoUser, provider, providerUserId, claims, ssoConfigData); } /// - /// Provision an SSO-linked Bitwarden user. + /// This function seeks to set up the org user record or create a new user record based on the conditions + /// below. + /// /// This handles three different scenarios: /// 1. Creating an SsoUser link for an existing User and OrganizationUser /// - User is a member of the organization, but hasn't authenticated with the org's SSO provider before. @@ -488,8 +508,7 @@ public class AccountController : Controller /// The SSO configuration for the organization. /// Guaranteed to return the user to sign in as well as the found organization and org user. /// An exception if the user cannot be provisioned as requested. - private async Task<(User user, Organization foundOrganization, OrganizationUser foundOrgUser)> - AutoProvisionUserAsync( + private async Task<(User resolvedUser, Organization foundOrganization, OrganizationUser foundOrgUser)> CreateUserAndOrgUserConditionallyAsync( string provider, string providerUserId, IEnumerable claims, @@ -497,10 +516,11 @@ public class AccountController : Controller SsoConfigurationData ssoConfigData ) { + // Try to get the email from the claims as we don't know if we have a user record yet. var name = GetName(claims, ssoConfigData.GetAdditionalNameClaimTypes()); var email = TryGetEmailAddress(claims, ssoConfigData, providerUserId); - User existingUser = null; + User? possibleExistingUser; if (string.IsNullOrWhiteSpace(userIdentifier)) { if (string.IsNullOrWhiteSpace(email)) @@ -508,51 +528,74 @@ public class AccountController : Controller throw new Exception(_i18nService.T("CannotFindEmailClaim")); } - existingUser = await _userRepository.GetByEmailAsync(email); + possibleExistingUser = await _userRepository.GetByEmailAsync(email); } else { - existingUser = await GetUserFromManualLinkingDataAsync(userIdentifier); + possibleExistingUser = await GetUserFromManualLinkingDataAsync(userIdentifier); } - // Try to find the org (we error if we can't find an org) - var organization = await TryGetOrganizationByProviderAsync(provider); + // Find the org (we error if we can't find an org because no org is not valid) + var organization = await GetOrganizationByProviderAsync(provider); // Try to find an org user (null org user possible and valid here) - var orgUser = await TryGetOrganizationUserByUserAndOrgOrEmail(existingUser, organization.Id, email); + var possibleOrgUser = await GetOrganizationUserByUserAndOrgIdOrEmailAsync(possibleExistingUser, organization.Id, email); //---------------------------------------------------- // Scenario 1: We've found the user in the User table //---------------------------------------------------- - if (existingUser != null) + if (possibleExistingUser != null) { - if (existingUser.UsesKeyConnector && - (orgUser == null || orgUser.Status == OrganizationUserStatusType.Invited)) + User guaranteedExistingUser = possibleExistingUser; + + if (guaranteedExistingUser.UsesKeyConnector && + (possibleOrgUser == null || possibleOrgUser.Status == OrganizationUserStatusType.Invited)) { throw new Exception(_i18nService.T("UserAlreadyExistsKeyConnector")); } - // If the user already exists in Bitwarden, we require that the user already be in the org, - // and that they are either Accepted or Confirmed. - if (orgUser == null) + OrganizationUser guaranteedOrgUser = possibleOrgUser ?? throw new Exception(_i18nService.T("UserAlreadyExistsInviteProcess")); + + /* + * ---------------------------------------------------- + * Critical Code Check Here + * + * We want to ensure a user is not in the invited state + * explicitly. User's in the invited state should not + * be able to authenticate via SSO. + * + * See internal doc called "Added Context for SSO Login + * Flows" for further details. + * ---------------------------------------------------- + */ + if (guaranteedOrgUser.Status == OrganizationUserStatusType.Invited) { - // Org User is not created - no invite has been sent - throw new Exception(_i18nService.T("UserAlreadyExistsInviteProcess")); + // Org User is invited – must accept via email first + throw new Exception( + _i18nService.T("AcceptInviteBeforeUsingSSO", organization.DisplayName())); } - EnsureAcceptedOrConfirmedOrgUserStatus(orgUser.Status, organization.DisplayName()); + // If the user already exists in Bitwarden, we require that the user already be in the org, + // and that they are either Accepted or Confirmed. + EnforceAllowedOrgUserStatus( + guaranteedOrgUser.Status, + allowedStatuses: [ + OrganizationUserStatusType.Accepted, + OrganizationUserStatusType.Confirmed + ], + organization.DisplayName()); // Since we're in the auto-provisioning logic, this means that the user exists, but they have not // authenticated with the org's SSO provider before now (otherwise we wouldn't be auto-provisioning them). // We've verified that the user is Accepted or Confnirmed, so we can create an SsoUser link and proceed // with authentication. - await CreateSsoUserRecordAsync(providerUserId, existingUser.Id, organization.Id, orgUser); + await CreateSsoUserRecordAsync(providerUserId, guaranteedExistingUser.Id, organization.Id, guaranteedOrgUser); - return (existingUser, organization, orgUser); + return (guaranteedExistingUser, organization, guaranteedOrgUser); } // Before any user creation - if Org User doesn't exist at this point - make sure there are enough seats to add one - if (orgUser == null && organization.Seats.HasValue) + if (possibleOrgUser == null && organization.Seats.HasValue) { var occupiedSeats = await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); @@ -584,6 +627,11 @@ public class AccountController : Controller } // If the email domain is verified, we can mark the email as verified + if (string.IsNullOrWhiteSpace(email)) + { + throw new Exception(_i18nService.T("CannotFindEmailClaim")); + } + var emailVerified = false; var emailDomain = CoreHelpers.GetEmailDomain(email); if (!string.IsNullOrWhiteSpace(emailDomain)) @@ -596,29 +644,29 @@ public class AccountController : Controller //-------------------------------------------------- // Scenarios 2 and 3: We need to register a new user //-------------------------------------------------- - var user = new User + var newUser = new User { Name = name, Email = email, EmailVerified = emailVerified, ApiKey = CoreHelpers.SecureRandomString(30) }; - await _registerUserCommand.RegisterUser(user); + await _registerUserCommand.RegisterUser(newUser); // If the organization has 2fa policy enabled, make sure to default jit user 2fa to email var twoFactorPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.TwoFactorAuthentication); if (twoFactorPolicy != null && twoFactorPolicy.Enabled) { - user.SetTwoFactorProviders(new Dictionary + newUser.SetTwoFactorProviders(new Dictionary { [TwoFactorProviderType.Email] = new TwoFactorProvider { - MetaData = new Dictionary { ["Email"] = user.Email.ToLowerInvariant() }, + MetaData = new Dictionary { ["Email"] = newUser.Email.ToLowerInvariant() }, Enabled = true } }); - await _userService.UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.Email); + await _userService.UpdateTwoFactorProviderAsync(newUser, TwoFactorProviderType.Email); } //----------------------------------------------------------------- @@ -626,16 +674,16 @@ public class AccountController : Controller // This means that an invitation was not sent for this user and we // need to establish their invited status now. //----------------------------------------------------------------- - if (orgUser == null) + if (possibleOrgUser == null) { - orgUser = new OrganizationUser + possibleOrgUser = new OrganizationUser { OrganizationId = organization.Id, - UserId = user.Id, + UserId = newUser.Id, Type = OrganizationUserType.User, Status = OrganizationUserStatusType.Invited }; - await _organizationUserRepository.CreateAsync(orgUser); + await _organizationUserRepository.CreateAsync(possibleOrgUser); } //----------------------------------------------------------------- @@ -645,14 +693,14 @@ public class AccountController : Controller //----------------------------------------------------------------- else { - orgUser.UserId = user.Id; - await _organizationUserRepository.ReplaceAsync(orgUser); + possibleOrgUser.UserId = newUser.Id; + await _organizationUserRepository.ReplaceAsync(possibleOrgUser); } // Create the SsoUser record to link the user to the SSO provider. - await CreateSsoUserRecordAsync(providerUserId, user.Id, organization.Id, orgUser); + await CreateSsoUserRecordAsync(providerUserId, newUser.Id, organization.Id, possibleOrgUser); - return (user, organization, orgUser); + return (newUser, organization, possibleOrgUser); } /// @@ -666,23 +714,31 @@ public class AccountController : Controller /// Thrown if the organization cannot be resolved from provider; /// the organization user cannot be found; or the organization user status is not allowed. private async Task PreventOrgUserLoginIfStatusInvalidAsync( - Organization organization, + Organization? organization, string provider, - OrganizationUser orgUser, + OrganizationUser? orgUser, User user) { // Lazily get organization if not already known - organization ??= await TryGetOrganizationByProviderAsync(provider); + organization ??= await GetOrganizationByProviderAsync(provider); // Lazily get the org user if not already known - orgUser ??= await TryGetOrganizationUserByUserAndOrgOrEmail( + orgUser ??= await GetOrganizationUserByUserAndOrgIdOrEmailAsync( user, organization.Id, user.Email); if (orgUser != null) { - EnsureAcceptedOrConfirmedOrgUserStatus(orgUser.Status, organization.DisplayName()); + // Invited is allowed at this point because we know the user is trying to accept an org invite. + EnforceAllowedOrgUserStatus( + orgUser.Status, + allowedStatuses: [ + OrganizationUserStatusType.Invited, + OrganizationUserStatusType.Accepted, + OrganizationUserStatusType.Confirmed, + ], + organization.DisplayName()); } else { @@ -690,9 +746,9 @@ public class AccountController : Controller } } - private async Task GetUserFromManualLinkingDataAsync(string userIdentifier) + private async Task GetUserFromManualLinkingDataAsync(string userIdentifier) { - User user = null; + User? user = null; var split = userIdentifier.Split(","); if (split.Length < 2) { @@ -728,7 +784,7 @@ public class AccountController : Controller /// /// Org id string from SSO scheme property /// Errors if the provider string is not a valid org id guid or if the org cannot be found by the id. - private async Task TryGetOrganizationByProviderAsync(string provider) + private async Task GetOrganizationByProviderAsync(string provider) { if (!Guid.TryParse(provider, out var organizationId)) { @@ -755,12 +811,12 @@ public class AccountController : Controller /// Organization id from the provider data. /// Email to use as a fallback in case of an invited user not in the Org Users /// table yet. - private async Task TryGetOrganizationUserByUserAndOrgOrEmail( - User user, + private async Task GetOrganizationUserByUserAndOrgIdOrEmailAsync( + User? user, Guid organizationId, - string email) + string? email) { - OrganizationUser orgUser = null; + OrganizationUser? orgUser = null; // Try to find OrgUser via existing User Id. // This covers any OrganizationUser state after they have accepted an invite. @@ -772,44 +828,40 @@ public class AccountController : Controller // If no Org User found by Existing User Id - search all the organization's users via email. // This covers users who are Invited but haven't accepted their invite yet. - orgUser ??= await _organizationUserRepository.GetByOrganizationEmailAsync(organizationId, email); + if (email != null) + { + orgUser ??= await _organizationUserRepository.GetByOrganizationEmailAsync(organizationId, email); + } return orgUser; } - private void EnsureAcceptedOrConfirmedOrgUserStatus( - OrganizationUserStatusType status, - string organizationDisplayName) + private void EnforceAllowedOrgUserStatus( + OrganizationUserStatusType statusToCheckAgainst, + OrganizationUserStatusType[] allowedStatuses, + string organizationDisplayNameForLogging) { - // The only permissible org user statuses allowed. - OrganizationUserStatusType[] allowedStatuses = - [OrganizationUserStatusType.Accepted, OrganizationUserStatusType.Confirmed]; - // if this status is one of the allowed ones, just return - if (allowedStatuses.Contains(status)) + if (allowedStatuses.Contains(statusToCheckAgainst)) { return; } // otherwise throw the appropriate exception - switch (status) + switch (statusToCheckAgainst) { - case OrganizationUserStatusType.Invited: - // Org User is invited – must accept via email first - throw new Exception( - _i18nService.T("AcceptInviteBeforeUsingSSO", organizationDisplayName)); case OrganizationUserStatusType.Revoked: // Revoked users may not be (auto)‑provisioned throw new Exception( - _i18nService.T("OrganizationUserAccessRevoked", organizationDisplayName)); + _i18nService.T("OrganizationUserAccessRevoked", organizationDisplayNameForLogging)); default: // anything else is “unknown” throw new Exception( - _i18nService.T("OrganizationUserUnknownStatus", organizationDisplayName)); + _i18nService.T("OrganizationUserUnknownStatus", organizationDisplayNameForLogging)); } } - private IActionResult InvalidJson(string errorMessageKey, Exception ex = null) + private IActionResult InvalidJson(string errorMessageKey, Exception? ex = null) { Response.StatusCode = ex == null ? 400 : 500; return Json(new ErrorResponseModel(_i18nService.T(errorMessageKey)) @@ -820,7 +872,7 @@ public class AccountController : Controller }); } - private string TryGetEmailAddressFromClaims(IEnumerable claims, IEnumerable additionalClaimTypes) + private string? TryGetEmailAddressFromClaims(IEnumerable claims, IEnumerable additionalClaimTypes) { var filteredClaims = claims.Where(c => !string.IsNullOrWhiteSpace(c.Value) && c.Value.Contains("@")); @@ -842,6 +894,8 @@ public class AccountController : Controller return null; } + // FIXME: Update this file to be null safe and then delete the line below +#nullable disable private string GetName(IEnumerable claims, IEnumerable additionalClaimTypes) { var filteredClaims = claims.Where(c => !string.IsNullOrWhiteSpace(c.Value)); @@ -865,6 +919,7 @@ public class AccountController : Controller return null; } +#nullable restore private async Task CreateSsoUserRecordAsync(string providerUserId, Guid userId, Guid orgId, OrganizationUser orgUser) @@ -886,6 +941,8 @@ public class AccountController : Controller await _ssoUserRepository.CreateAsync(ssoUser); } + // FIXME: Update this file to be null safe and then delete the line below +#nullable disable private void ProcessLoginCallback(AuthenticateResult externalResult, List localClaims, AuthenticationProperties localSignInProps) { @@ -936,12 +993,13 @@ public class AccountController : Controller return (logoutId, logout?.PostLogoutRedirectUri, externalAuthenticationScheme); } +#nullable restore /** * Tries to get a user's email from the claims and SSO configuration data or the provider user id if * the claims email extraction returns null. */ - private string TryGetEmailAddress( + private string? TryGetEmailAddress( IEnumerable claims, SsoConfigurationData config, string providerUserId) diff --git a/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs b/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs index 7dbc98d261..0fe37d89fd 100644 --- a/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs +++ b/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs @@ -74,17 +74,6 @@ public class AccountControllerTest return resolvedAuthService; } - private static void InvokeEnsureOrgUserStatusAllowed( - AccountController controller, - OrganizationUserStatusType status) - { - var method = typeof(AccountController).GetMethod( - "EnsureAcceptedOrConfirmedOrgUserStatus", - BindingFlags.Instance | BindingFlags.NonPublic); - Assert.NotNull(method); - method.Invoke(controller, [status, "Org"]); - } - private static AuthenticateResult BuildSuccessfulExternalAuth(Guid orgId, string providerUserId, string email) { var claims = new[] @@ -241,82 +230,6 @@ public class AccountControllerTest return counts; } - [Theory, BitAutoData] - public void EnsureOrgUserStatusAllowed_AllowsAcceptedAndConfirmed( - SutProvider sutProvider) - { - // Arrange - sutProvider.GetDependency() - .T(Arg.Any(), Arg.Any()) - .Returns(ci => (string)ci[0]!); - - // Act - var ex1 = Record.Exception(() => - InvokeEnsureOrgUserStatusAllowed(sutProvider.Sut, OrganizationUserStatusType.Accepted)); - var ex2 = Record.Exception(() => - InvokeEnsureOrgUserStatusAllowed(sutProvider.Sut, OrganizationUserStatusType.Confirmed)); - - // Assert - Assert.Null(ex1); - Assert.Null(ex2); - } - - [Theory, BitAutoData] - public void EnsureOrgUserStatusAllowed_Invited_ThrowsAcceptInvite( - SutProvider sutProvider) - { - // Arrange - sutProvider.GetDependency() - .T(Arg.Any(), Arg.Any()) - .Returns(ci => (string)ci[0]!); - - // Act - var ex = Assert.Throws(() => - InvokeEnsureOrgUserStatusAllowed(sutProvider.Sut, OrganizationUserStatusType.Invited)); - - // Assert - Assert.IsType(ex.InnerException); - Assert.Equal("AcceptInviteBeforeUsingSSO", ex.InnerException!.Message); - } - - [Theory, BitAutoData] - public void EnsureOrgUserStatusAllowed_Revoked_ThrowsAccessRevoked( - SutProvider sutProvider) - { - // Arrange - sutProvider.GetDependency() - .T(Arg.Any(), Arg.Any()) - .Returns(ci => (string)ci[0]!); - - // Act - var ex = Assert.Throws(() => - InvokeEnsureOrgUserStatusAllowed(sutProvider.Sut, OrganizationUserStatusType.Revoked)); - - // Assert - Assert.IsType(ex.InnerException); - Assert.Equal("OrganizationUserAccessRevoked", ex.InnerException!.Message); - } - - [Theory, BitAutoData] - public void EnsureOrgUserStatusAllowed_UnknownStatus_ThrowsUnknown( - SutProvider sutProvider) - { - // Arrange - sutProvider.GetDependency() - .T(Arg.Any(), Arg.Any()) - .Returns(ci => (string)ci[0]!); - - var unknown = (OrganizationUserStatusType)999; - - // Act - var ex = Assert.Throws(() => - InvokeEnsureOrgUserStatusAllowed(sutProvider.Sut, unknown)); - - // Assert - Assert.IsType(ex.InnerException); - Assert.Equal("OrganizationUserUnknownStatus", ex.InnerException!.Message); - } - [Theory, BitAutoData] public async Task ExternalCallback_PreventNonCompliantTrue_ExistingUser_NoOrgUser_ThrowsCouldNotFindOrganizationUser( SutProvider sutProvider) @@ -357,7 +270,7 @@ public class AccountControllerTest } [Theory, BitAutoData] - public async Task ExternalCallback_PreventNonCompliantTrue_ExistingUser_OrgUserInvited_ThrowsAcceptInvite( + public async Task ExternalCallback_PreventNonCompliantTrue_ExistingUser_OrgUserInvited_AllowsLogin( SutProvider sutProvider) { // Arrange @@ -374,7 +287,7 @@ public class AccountControllerTest }; var authResult = BuildSuccessfulExternalAuth(orgId, providerUserId, user.Email!); - SetupHttpContextWithAuth(sutProvider, authResult); + var authService = SetupHttpContextWithAuth(sutProvider, authResult); sutProvider.GetDependency() .T(Arg.Any(), Arg.Any()) @@ -392,9 +305,23 @@ public class AccountControllerTest sutProvider.GetDependency() .GetAuthorizationContextAsync("~/").Returns((AuthorizationRequest?)null); - // Act + Assert - var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.ExternalCallback()); - Assert.Equal("AcceptInviteBeforeUsingSSO", ex.Message); + // Act + var result = await sutProvider.Sut.ExternalCallback(); + + // Assert + var redirect = Assert.IsType(result); + Assert.Equal("~/", redirect.Url); + + await authService.Received().SignInAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + + await authService.Received().SignOutAsync( + Arg.Any(), + AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme, + Arg.Any()); } [Theory, BitAutoData] @@ -930,13 +857,13 @@ public class AccountControllerTest } [Theory, BitAutoData] - public async Task AutoProvisionUserAsync_WithExistingAcceptedUser_CreatesSsoLinkAndReturnsUser( + public async Task CreateUserAndOrgUserConditionallyAsync_WithExistingAcceptedUser_CreatesSsoLinkAndReturnsUser( SutProvider sutProvider) { // Arrange var orgId = Guid.NewGuid(); - var providerUserId = "ext-456"; - var email = "jit@example.com"; + var providerUserId = "provider-user-id"; + var email = "user@example.com"; var existingUser = new User { Id = Guid.NewGuid(), Email = email }; var organization = new Organization { Id = orgId, Name = "Org" }; var orgUser = new OrganizationUser @@ -965,12 +892,12 @@ public class AccountControllerTest var config = new SsoConfigurationData(); var method = typeof(AccountController).GetMethod( - "AutoProvisionUserAsync", + "CreateUserAndOrgUserConditionallyAsync", BindingFlags.Instance | BindingFlags.NonPublic); Assert.NotNull(method); // Act - var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method!.Invoke(sutProvider.Sut, new object[] + var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method.Invoke(sutProvider.Sut, new object[] { orgId.ToString(), providerUserId, @@ -992,6 +919,61 @@ public class AccountControllerTest EventType.OrganizationUser_FirstSsoLogin); } + [Theory, BitAutoData] + public async Task CreateUserAndOrgUserConditionallyAsync_WithExistingInvitedUser_ThrowsAcceptInviteBeforeUsingSSO( + SutProvider sutProvider) + { + // Arrange + var orgId = Guid.NewGuid(); + var providerUserId = "provider-user-id"; + var email = "user@example.com"; + var existingUser = new User { Id = Guid.NewGuid(), Email = email, UsesKeyConnector = false }; + var organization = new Organization { Id = orgId, Name = "Org" }; + var orgUser = new OrganizationUser + { + OrganizationId = orgId, + UserId = existingUser.Id, + Status = OrganizationUserStatusType.Invited, + Type = OrganizationUserType.User + }; + + // i18n returns the key so we can assert on message contents + sutProvider.GetDependency() + .T(Arg.Any(), Arg.Any()) + .Returns(ci => (string)ci[0]!); + + // Arrange repository expectations for the flow + sutProvider.GetDependency().GetByEmailAsync(email).Returns(existingUser); + sutProvider.GetDependency().GetByIdAsync(orgId).Returns(organization); + sutProvider.GetDependency().GetManyByUserAsync(existingUser.Id) + .Returns(new List { orgUser }); + + var claims = new[] + { + new Claim(JwtClaimTypes.Email, email), + new Claim(JwtClaimTypes.Name, "Invited User") + } as IEnumerable; + var config = new SsoConfigurationData(); + + var method = typeof(AccountController).GetMethod( + "CreateUserAndOrgUserConditionallyAsync", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + + // Act + Assert + var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method.Invoke(sutProvider.Sut, new object[] + { + orgId.ToString(), + providerUserId, + claims, + null!, + config + })!; + + var ex = await Assert.ThrowsAsync(async () => await task); + Assert.Equal("AcceptInviteBeforeUsingSSO", ex.Message); + } + /// /// PM-24579: Temporary comparison test to ensure the feature flag ON does not /// regress lookup counts compared to OFF. When removing the flag, delete this From 356e4263d213bdde9e229953010a8fa2e55d11f5 Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Fri, 7 Nov 2025 11:04:27 +0100 Subject: [PATCH 06/77] Add feature flags for desktop-ui migration (#6548) --- src/Core/Constants.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index d2d1062761..a6858e4285 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -145,6 +145,11 @@ public static class FeatureFlagKeys public const string AccountRecoveryCommand = "pm-25581-prevent-provider-account-recovery"; public const string PolicyValidatorsRefactor = "pm-26423-refactor-policy-side-effects"; + /* Architecture */ + public const string DesktopMigrationMilestone1 = "desktop-ui-migration-milestone-1"; + public const string DesktopMigrationMilestone2 = "desktop-ui-migration-milestone-2"; + public const string DesktopMigrationMilestone3 = "desktop-ui-migration-milestone-3"; + /* Auth Team */ public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence"; public const string EmailVerification = "email-verification"; From d1fecc2a0f96eef119f536c8488323e088f99d44 Mon Sep 17 00:00:00 2001 From: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com> Date: Fri, 7 Nov 2025 09:48:19 -0600 Subject: [PATCH 07/77] chore: remove custom permissions feature flag definition, refs PM-20168 (#6551) --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index a6858e4285..eaa8f9163a 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -138,7 +138,6 @@ public static class FeatureFlagKeys public const string PolicyRequirements = "pm-14439-policy-requirements"; public const string ScimInviteUserOptimization = "pm-16811-optimize-invite-user-flow-to-fail-fast"; public const string EventBasedOrganizationIntegrations = "event-based-organization-integrations"; - public const string SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions"; public const string CreateDefaultLocation = "pm-19467-create-default-location"; public const string AutomaticConfirmUsers = "pm-19934-auto-confirm-organization-users"; public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache"; From 22fe50c67acb82283e71e864118ffb79b4c5d0e9 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Fri, 7 Nov 2025 13:05:05 -0600 Subject: [PATCH 08/77] Expand coupon.applies_to (#6554) --- src/Core/Services/Implementations/StripePaymentService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 2707401134..ff99393955 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -641,7 +641,7 @@ public class StripePaymentService : IPaymentService } var subscription = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, - new SubscriptionGetOptions { Expand = ["customer", "discounts", "test_clock"] }); + new SubscriptionGetOptions { Expand = ["customer.discount.coupon.applies_to", "discounts.coupon.applies_to", "test_clock"] }); subscriptionInfo.Subscription = new SubscriptionInfo.BillingSubscription(subscription); From 7d39efe29ff122fc9c411bb504cf7ffe34386f55 Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Mon, 10 Nov 2025 08:40:40 +0100 Subject: [PATCH 09/77] [PM-27575] Add support for loading Mailer templates from disk (#6520) Adds support for overloading mail templates from disk. --- .../Mail/Mailer/HandlebarMailRenderer.cs | 59 ++++++- .../Mailer/HandlebarMailRendererTests.cs | 154 +++++++++++++++++- test/Core.Test/Platform/Mailer/MailerTest.cs | 8 +- 3 files changed, 216 insertions(+), 5 deletions(-) diff --git a/src/Core/Platform/Mail/Mailer/HandlebarMailRenderer.cs b/src/Core/Platform/Mail/Mailer/HandlebarMailRenderer.cs index 608d6d6be0..baba5b8015 100644 --- a/src/Core/Platform/Mail/Mailer/HandlebarMailRenderer.cs +++ b/src/Core/Platform/Mail/Mailer/HandlebarMailRenderer.cs @@ -1,7 +1,9 @@ #nullable enable using System.Collections.Concurrent; using System.Reflection; +using Bit.Core.Settings; using HandlebarsDotNet; +using Microsoft.Extensions.Logging; namespace Bit.Core.Platform.Mail.Mailer; public class HandlebarMailRenderer : IMailRenderer @@ -9,7 +11,7 @@ public class HandlebarMailRenderer : IMailRenderer /// /// Lazy-initialized Handlebars instance. Thread-safe and ensures initialization occurs only once. /// - private readonly Lazy> _handlebarsTask = new(InitializeHandlebarsAsync, LazyThreadSafetyMode.ExecutionAndPublication); + private readonly Lazy> _handlebarsTask; /// /// Helper function that returns the handlebar instance. @@ -21,6 +23,17 @@ public class HandlebarMailRenderer : IMailRenderer /// private readonly ConcurrentDictionary>>> _templateCache = new(); + private readonly ILogger _logger; + private readonly GlobalSettings _globalSettings; + + public HandlebarMailRenderer(ILogger logger, GlobalSettings globalSettings) + { + _logger = logger; + _globalSettings = globalSettings; + + _handlebarsTask = new Lazy>(InitializeHandlebarsAsync, LazyThreadSafetyMode.ExecutionAndPublication); + } + public async Task<(string html, string txt)> RenderAsync(BaseMailView model) { var html = await CompileTemplateAsync(model, "html"); @@ -53,19 +66,59 @@ public class HandlebarMailRenderer : IMailRenderer return handlebars.Compile(source); } - private static async Task ReadSourceAsync(Assembly assembly, string template) + private async Task ReadSourceAsync(Assembly assembly, string template) { if (assembly.GetManifestResourceNames().All(f => f != template)) { throw new FileNotFoundException("Template not found: " + template); } + var diskSource = await ReadSourceFromDiskAsync(template); + if (!string.IsNullOrWhiteSpace(diskSource)) + { + return diskSource; + } + await using var s = assembly.GetManifestResourceStream(template)!; using var sr = new StreamReader(s); return await sr.ReadToEndAsync(); } - private static async Task InitializeHandlebarsAsync() + private async Task ReadSourceFromDiskAsync(string template) + { + if (!_globalSettings.SelfHosted) + { + return null; + } + + try + { + var diskPath = Path.GetFullPath(Path.Combine(_globalSettings.MailTemplateDirectory, template)); + var baseDirectory = Path.GetFullPath(_globalSettings.MailTemplateDirectory); + + // Ensure the resolved path is within the configured directory + if (!diskPath.StartsWith(baseDirectory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) && + !diskPath.Equals(baseDirectory, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning("Template path traversal attempt detected: {Template}", template); + return null; + } + + if (File.Exists(diskPath)) + { + var fileContents = await File.ReadAllTextAsync(diskPath); + return fileContents; + } + } + catch (Exception e) + { + _logger.LogError(e, "Failed to read mail template from disk: {TemplateName}", template); + } + + return null; + } + + private async Task InitializeHandlebarsAsync() { var handlebars = Handlebars.Create(); diff --git a/test/Core.Test/Platform/Mailer/HandlebarMailRendererTests.cs b/test/Core.Test/Platform/Mailer/HandlebarMailRendererTests.cs index 1cc7504702..2559ae2b5f 100644 --- a/test/Core.Test/Platform/Mailer/HandlebarMailRendererTests.cs +++ b/test/Core.Test/Platform/Mailer/HandlebarMailRendererTests.cs @@ -1,5 +1,8 @@ using Bit.Core.Platform.Mail.Mailer; +using Bit.Core.Settings; using Bit.Core.Test.Platform.Mailer.TestMail; +using Microsoft.Extensions.Logging; +using NSubstitute; using Xunit; namespace Bit.Core.Test.Platform.Mailer; @@ -9,7 +12,10 @@ public class HandlebarMailRendererTests [Fact] public async Task RenderAsync_ReturnsExpectedHtmlAndTxt() { - var renderer = new HandlebarMailRenderer(); + var logger = Substitute.For>(); + var globalSettings = new GlobalSettings { SelfHosted = false }; + var renderer = new HandlebarMailRenderer(logger, globalSettings); + var view = new TestMailView { Name = "John Smith" }; var (html, txt) = await renderer.RenderAsync(view); @@ -17,4 +23,150 @@ public class HandlebarMailRendererTests Assert.Equal("Hello John Smith", html.Trim()); Assert.Equal("Hello John Smith", txt.Trim()); } + + [Fact] + public async Task RenderAsync_LoadsFromDisk_WhenSelfHostedAndFileExists() + { + var logger = Substitute.For>(); + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + + try + { + var globalSettings = new GlobalSettings + { + SelfHosted = true, + MailTemplateDirectory = tempDir + }; + + // Create test template files on disk + var htmlTemplatePath = Path.Combine(tempDir, "Bit.Core.Test.Platform.Mailer.TestMail.TestMailView.html.hbs"); + var txtTemplatePath = Path.Combine(tempDir, "Bit.Core.Test.Platform.Mailer.TestMail.TestMailView.text.hbs"); + await File.WriteAllTextAsync(htmlTemplatePath, "Custom HTML: {{Name}}"); + await File.WriteAllTextAsync(txtTemplatePath, "Custom TXT: {{Name}}"); + + var renderer = new HandlebarMailRenderer(logger, globalSettings); + var view = new TestMailView { Name = "Jane Doe" }; + + var (html, txt) = await renderer.RenderAsync(view); + + Assert.Equal("Custom HTML: Jane Doe", html.Trim()); + Assert.Equal("Custom TXT: Jane Doe", txt.Trim()); + } + finally + { + // Cleanup + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + [Theory] + [InlineData("../../../etc/passwd")] + [InlineData("../../../../malicious.txt")] + [InlineData("../../malicious.txt")] + [InlineData("../malicious.txt")] + public async Task ReadSourceFromDiskAsync_PrevenetsPathTraversal_WhenMaliciousPathProvided(string maliciousPath) + { + var logger = Substitute.For>(); + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + + try + { + var globalSettings = new GlobalSettings + { + SelfHosted = true, + MailTemplateDirectory = tempDir + }; + + // Create a malicious file outside the template directory + var maliciousFile = Path.Combine(Path.GetTempPath(), "malicious.txt"); + await File.WriteAllTextAsync(maliciousFile, "Malicious Content"); + + var renderer = new HandlebarMailRenderer(logger, globalSettings); + + // Use reflection to call the private ReadSourceFromDiskAsync method + var method = typeof(HandlebarMailRenderer).GetMethod("ReadSourceFromDiskAsync", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var task = (Task)method!.Invoke(renderer, new object[] { maliciousPath })!; + var result = await task; + + // Should return null and not load the malicious file + Assert.Null(result); + + // Verify that a warning was logged for the path traversal attempt + logger.Received(1).Log( + LogLevel.Warning, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>()); + + // Cleanup malicious file + if (File.Exists(maliciousFile)) + { + File.Delete(maliciousFile); + } + } + finally + { + // Cleanup + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + [Fact] + public async Task ReadSourceFromDiskAsync_AllowsValidFileWithDifferentCase_WhenCaseInsensitiveFileSystem() + { + var logger = Substitute.For>(); + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + + try + { + var globalSettings = new GlobalSettings + { + SelfHosted = true, + MailTemplateDirectory = tempDir + }; + + // Create a test template file + var templateFileName = "TestTemplate.hbs"; + var templatePath = Path.Combine(tempDir, templateFileName); + await File.WriteAllTextAsync(templatePath, "Test Content"); + + var renderer = new HandlebarMailRenderer(logger, globalSettings); + + // Try to read with different case (should work on case-insensitive file systems like Windows/macOS) + var method = typeof(HandlebarMailRenderer).GetMethod("ReadSourceFromDiskAsync", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var task = (Task)method!.Invoke(renderer, new object[] { templateFileName })!; + var result = await task; + + // Should successfully read the file + Assert.Equal("Test Content", result); + + // Verify no warning was logged + logger.DidNotReceive().Log( + LogLevel.Warning, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>()); + } + finally + { + // Cleanup + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } } diff --git a/test/Core.Test/Platform/Mailer/MailerTest.cs b/test/Core.Test/Platform/Mailer/MailerTest.cs index adaf458de0..ca9cb2a874 100644 --- a/test/Core.Test/Platform/Mailer/MailerTest.cs +++ b/test/Core.Test/Platform/Mailer/MailerTest.cs @@ -1,18 +1,24 @@ using Bit.Core.Models.Mail; using Bit.Core.Platform.Mail.Delivery; using Bit.Core.Platform.Mail.Mailer; +using Bit.Core.Settings; using Bit.Core.Test.Platform.Mailer.TestMail; +using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; namespace Bit.Core.Test.Platform.Mailer; + public class MailerTest { [Fact] public async Task SendEmailAsync() { + var logger = Substitute.For>(); + var globalSettings = new GlobalSettings { SelfHosted = false }; var deliveryService = Substitute.For(); - var mailer = new Core.Platform.Mail.Mailer.Mailer(new HandlebarMailRenderer(), deliveryService); + + var mailer = new Core.Platform.Mail.Mailer.Mailer(new HandlebarMailRenderer(logger, globalSettings), deliveryService); var mail = new TestMail.TestMail() { From e7f3b6b12f67c08fc270c4f2608a9db875f5181f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:27:44 +0000 Subject: [PATCH 10/77] [PM-26430] Remove Type property from PolicyRequestModel to use route parameter only (#6472) * Enhance PolicyRequestModel and SavePolicyRequest with validation for policy data and metadata. * Add integration tests for policy updates to validate handling of invalid data types in PolicyRequestModel and SavePolicyRequest. * Add missing using * Update PolicyRequestModel for null safety by making Data and ValidateAndSerializePolicyData nullable * Add integration tests for public PoliciesController to validate handling of invalid data types in policy updates. * Add PolicyDataValidator class for validating and serializing policy data and metadata based on policy type. * Refactor PolicyRequestModel, SavePolicyRequest, and PolicyUpdateRequestModel to utilize PolicyDataValidator for data validation and serialization, removing redundant methods and improving code clarity. * Update PolicyRequestModel and SavePolicyRequest to initialize Data and Metadata properties with empty dictionaries. * Refactor PolicyDataValidator to remove null checks for input data in validation methods * Rename test methods in SavePolicyRequestTests to reflect handling of empty data and metadata, and remove null assignments in test cases for improved clarity. * Remove Type property from PolicyRequestModel to use route parameter only * Run dotnet format * Enhance error handling in PolicyDataValidator to include field-specific details in BadRequestException messages. * Enhance PoliciesControllerTests to verify error messages for BadRequest responses by checking for specific field names in the response content. * refactor: Update PolicyRequestModel and SavePolicyRequest to use nullable dictionaries for Data and Metadata properties; enhance validation methods in PolicyDataValidator to handle null cases. * test: Add integration tests for handling policies with null data in PoliciesController * fix: Catch specific JsonException in PolicyDataValidator to improve error handling * test: Add unit tests for PolicyDataValidator to validate and serialize policy data and metadata * test: Remove PolicyType from PolicyRequestModel in PoliciesControllerTests * test: Update PolicyDataValidatorTests to validate organization data ownership metadata * Refactor PoliciesControllerTests to include policy type in PutVNext method calls --- .../Controllers/PoliciesController.cs | 13 ++----- .../Models/Request/PolicyRequestModel.cs | 8 ++--- .../Models/Request/SavePolicyRequest.cs | 7 ++-- .../Controllers/PoliciesControllerTests.cs | 10 ------ .../Models/Request/SavePolicyRequestTests.cs | 34 +++++++++---------- .../Controllers/PoliciesControllerTests.cs | 8 ++--- 6 files changed, 30 insertions(+), 50 deletions(-) diff --git a/src/Api/AdminConsole/Controllers/PoliciesController.cs b/src/Api/AdminConsole/Controllers/PoliciesController.cs index 1ee6dedf89..a5272413e2 100644 --- a/src/Api/AdminConsole/Controllers/PoliciesController.cs +++ b/src/Api/AdminConsole/Controllers/PoliciesController.cs @@ -209,23 +209,17 @@ public class PoliciesController : Controller throw new NotFoundException(); } - if (type != model.Type) - { - throw new BadRequestException("Mismatched policy type"); - } - - var policyUpdate = await model.ToPolicyUpdateAsync(orgId, _currentContext); + var policyUpdate = await model.ToPolicyUpdateAsync(orgId, type, _currentContext); var policy = await _savePolicyCommand.SaveAsync(policyUpdate); return new PolicyResponseModel(policy); } - [HttpPut("{type}/vnext")] [RequireFeatureAttribute(FeatureFlagKeys.CreateDefaultLocation)] [Authorize] - public async Task PutVNext(Guid orgId, [FromBody] SavePolicyRequest model) + public async Task PutVNext(Guid orgId, PolicyType type, [FromBody] SavePolicyRequest model) { - var savePolicyRequest = await model.ToSavePolicyModelAsync(orgId, _currentContext); + var savePolicyRequest = await model.ToSavePolicyModelAsync(orgId, type, _currentContext); var policy = _featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) ? await _vNextSavePolicyCommand.SaveAsync(savePolicyRequest) : @@ -233,5 +227,4 @@ public class PoliciesController : Controller return new PolicyResponseModel(policy); } - } diff --git a/src/Api/AdminConsole/Models/Request/PolicyRequestModel.cs b/src/Api/AdminConsole/Models/Request/PolicyRequestModel.cs index f9b9c18993..2dc7dfa7cd 100644 --- a/src/Api/AdminConsole/Models/Request/PolicyRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/PolicyRequestModel.cs @@ -9,20 +9,18 @@ namespace Bit.Api.AdminConsole.Models.Request; public class PolicyRequestModel { - [Required] - public PolicyType? Type { get; set; } [Required] public bool? Enabled { get; set; } public Dictionary? Data { get; set; } - public async Task ToPolicyUpdateAsync(Guid organizationId, ICurrentContext currentContext) + public async Task ToPolicyUpdateAsync(Guid organizationId, PolicyType type, ICurrentContext currentContext) { - var serializedData = PolicyDataValidator.ValidateAndSerialize(Data, Type!.Value); + var serializedData = PolicyDataValidator.ValidateAndSerialize(Data, type); var performedBy = new StandardUser(currentContext.UserId!.Value, await currentContext.OrganizationOwner(organizationId)); return new() { - Type = Type!.Value, + Type = type, OrganizationId = organizationId, Data = serializedData, Enabled = Enabled.GetValueOrDefault(), diff --git a/src/Api/AdminConsole/Models/Request/SavePolicyRequest.cs b/src/Api/AdminConsole/Models/Request/SavePolicyRequest.cs index 5c1acc1c36..2e2868a78a 100644 --- a/src/Api/AdminConsole/Models/Request/SavePolicyRequest.cs +++ b/src/Api/AdminConsole/Models/Request/SavePolicyRequest.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.Utilities; @@ -13,10 +14,10 @@ public class SavePolicyRequest public Dictionary? Metadata { get; set; } - public async Task ToSavePolicyModelAsync(Guid organizationId, ICurrentContext currentContext) + public async Task ToSavePolicyModelAsync(Guid organizationId, PolicyType type, ICurrentContext currentContext) { - var policyUpdate = await Policy.ToPolicyUpdateAsync(organizationId, currentContext); - var metadata = PolicyDataValidator.ValidateAndDeserializeMetadata(Metadata, Policy.Type!.Value); + var policyUpdate = await Policy.ToPolicyUpdateAsync(organizationId, type, currentContext); + var metadata = PolicyDataValidator.ValidateAndDeserializeMetadata(Metadata, type); var performedBy = new StandardUser(currentContext.UserId!.Value, await currentContext.OrganizationOwner(organizationId)); return new SavePolicyModel(policyUpdate, performedBy, metadata); diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/PoliciesControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/PoliciesControllerTests.cs index 79c31f956d..e4098ce9a9 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/PoliciesControllerTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/PoliciesControllerTests.cs @@ -67,7 +67,6 @@ public class PoliciesControllerTests : IClassFixture, IAs { Policy = new PolicyRequestModel { - Type = policyType, Enabled = true, }, Metadata = new Dictionary @@ -148,7 +147,6 @@ public class PoliciesControllerTests : IClassFixture, IAs { Policy = new PolicyRequestModel { - Type = policyType, Enabled = true, Data = new Dictionary { @@ -218,7 +216,6 @@ public class PoliciesControllerTests : IClassFixture, IAs var policyType = PolicyType.MasterPassword; var request = new PolicyRequestModel { - Type = policyType, Enabled = true, Data = new Dictionary { @@ -244,7 +241,6 @@ public class PoliciesControllerTests : IClassFixture, IAs var policyType = PolicyType.SendOptions; var request = new PolicyRequestModel { - Type = policyType, Enabled = true, Data = new Dictionary { @@ -267,7 +263,6 @@ public class PoliciesControllerTests : IClassFixture, IAs var policyType = PolicyType.ResetPassword; var request = new PolicyRequestModel { - Type = policyType, Enabled = true, Data = new Dictionary { @@ -292,7 +287,6 @@ public class PoliciesControllerTests : IClassFixture, IAs { Policy = new PolicyRequestModel { - Type = policyType, Enabled = true, Data = new Dictionary { @@ -321,7 +315,6 @@ public class PoliciesControllerTests : IClassFixture, IAs { Policy = new PolicyRequestModel { - Type = policyType, Enabled = true, Data = new Dictionary { @@ -347,7 +340,6 @@ public class PoliciesControllerTests : IClassFixture, IAs { Policy = new PolicyRequestModel { - Type = policyType, Enabled = true, Data = new Dictionary { @@ -371,7 +363,6 @@ public class PoliciesControllerTests : IClassFixture, IAs var policyType = PolicyType.SingleOrg; var request = new PolicyRequestModel { - Type = policyType, Enabled = true, Data = null }; @@ -393,7 +384,6 @@ public class PoliciesControllerTests : IClassFixture, IAs { Policy = new PolicyRequestModel { - Type = policyType, Enabled = true, Data = null }, diff --git a/test/Api.Test/AdminConsole/Models/Request/SavePolicyRequestTests.cs b/test/Api.Test/AdminConsole/Models/Request/SavePolicyRequestTests.cs index 75236fd719..163d66aeb4 100644 --- a/test/Api.Test/AdminConsole/Models/Request/SavePolicyRequestTests.cs +++ b/test/Api.Test/AdminConsole/Models/Request/SavePolicyRequestTests.cs @@ -24,11 +24,11 @@ public class SavePolicyRequestTests currentContext.OrganizationOwner(organizationId).Returns(true); var testData = new Dictionary { { "test", "value" } }; + var policyType = PolicyType.TwoFactorAuthentication; var model = new SavePolicyRequest { Policy = new PolicyRequestModel { - Type = PolicyType.TwoFactorAuthentication, Enabled = true, Data = testData }, @@ -36,7 +36,7 @@ public class SavePolicyRequestTests }; // Act - var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + var result = await model.ToSavePolicyModelAsync(organizationId, policyType, currentContext); // Assert Assert.Equal(PolicyType.TwoFactorAuthentication, result.PolicyUpdate.Type); @@ -63,17 +63,17 @@ public class SavePolicyRequestTests currentContext.UserId.Returns(userId); currentContext.OrganizationOwner(organizationId).Returns(false); + var policyType = PolicyType.SingleOrg; var model = new SavePolicyRequest { Policy = new PolicyRequestModel { - Type = PolicyType.SingleOrg, Enabled = false } }; // Act - var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + var result = await model.ToSavePolicyModelAsync(organizationId, policyType, currentContext); // Assert Assert.Null(result.PolicyUpdate.Data); @@ -93,17 +93,17 @@ public class SavePolicyRequestTests currentContext.UserId.Returns(userId); currentContext.OrganizationOwner(organizationId).Returns(true); + var policyType = PolicyType.SingleOrg; var model = new SavePolicyRequest { Policy = new PolicyRequestModel { - Type = PolicyType.SingleOrg, Enabled = false } }; // Act - var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + var result = await model.ToSavePolicyModelAsync(organizationId, policyType, currentContext); // Assert Assert.Null(result.PolicyUpdate.Data); @@ -124,11 +124,11 @@ public class SavePolicyRequestTests currentContext.UserId.Returns(userId); currentContext.OrganizationOwner(organizationId).Returns(true); + var policyType = PolicyType.OrganizationDataOwnership; var model = new SavePolicyRequest { Policy = new PolicyRequestModel { - Type = PolicyType.OrganizationDataOwnership, Enabled = true }, Metadata = new Dictionary @@ -138,7 +138,7 @@ public class SavePolicyRequestTests }; // Act - var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + var result = await model.ToSavePolicyModelAsync(organizationId, policyType, currentContext); // Assert Assert.IsType(result.Metadata); @@ -156,17 +156,17 @@ public class SavePolicyRequestTests currentContext.UserId.Returns(userId); currentContext.OrganizationOwner(organizationId).Returns(true); + var policyType = PolicyType.OrganizationDataOwnership; var model = new SavePolicyRequest { Policy = new PolicyRequestModel { - Type = PolicyType.OrganizationDataOwnership, Enabled = true } }; // Act - var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + var result = await model.ToSavePolicyModelAsync(organizationId, policyType, currentContext); // Assert Assert.NotNull(result); @@ -193,12 +193,11 @@ public class SavePolicyRequestTests currentContext.UserId.Returns(userId); currentContext.OrganizationOwner(organizationId).Returns(true); - + var policyType = PolicyType.ResetPassword; var model = new SavePolicyRequest { Policy = new PolicyRequestModel { - Type = PolicyType.ResetPassword, Enabled = true, Data = _complexData }, @@ -206,7 +205,7 @@ public class SavePolicyRequestTests }; // Act - var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + var result = await model.ToSavePolicyModelAsync(organizationId, policyType, currentContext); // Assert var deserializedData = JsonSerializer.Deserialize>(result.PolicyUpdate.Data); @@ -234,11 +233,11 @@ public class SavePolicyRequestTests currentContext.UserId.Returns(userId); currentContext.OrganizationOwner(organizationId).Returns(true); + var policyType = PolicyType.MaximumVaultTimeout; var model = new SavePolicyRequest { Policy = new PolicyRequestModel { - Type = PolicyType.MaximumVaultTimeout, Enabled = true }, Metadata = new Dictionary @@ -248,7 +247,7 @@ public class SavePolicyRequestTests }; // Act - var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + var result = await model.ToSavePolicyModelAsync(organizationId, policyType, currentContext); // Assert Assert.NotNull(result); @@ -266,19 +265,18 @@ public class SavePolicyRequestTests currentContext.OrganizationOwner(organizationId).Returns(true); var errorDictionary = BuildErrorDictionary(); - + var policyType = PolicyType.OrganizationDataOwnership; var model = new SavePolicyRequest { Policy = new PolicyRequestModel { - Type = PolicyType.OrganizationDataOwnership, Enabled = true }, Metadata = errorDictionary }; // Act - var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + var result = await model.ToSavePolicyModelAsync(organizationId, policyType, currentContext); // Assert Assert.NotNull(result); diff --git a/test/Api.Test/Controllers/PoliciesControllerTests.cs b/test/Api.Test/Controllers/PoliciesControllerTests.cs index 73cdd0fe29..89d6ddefdc 100644 --- a/test/Api.Test/Controllers/PoliciesControllerTests.cs +++ b/test/Api.Test/Controllers/PoliciesControllerTests.cs @@ -487,14 +487,14 @@ public class PoliciesControllerTests .Returns(policy); // Act - var result = await sutProvider.Sut.PutVNext(orgId, model); + var result = await sutProvider.Sut.PutVNext(orgId, policy.Type, model); // Assert await sutProvider.GetDependency() .Received(1) .SaveAsync(Arg.Is( m => m.PolicyUpdate.OrganizationId == orgId && - m.PolicyUpdate.Type == model.Policy.Type && + m.PolicyUpdate.Type == policy.Type && m.PolicyUpdate.Enabled == model.Policy.Enabled && m.PerformedBy.UserId == userId && m.PerformedBy.IsOrganizationOwnerOrProvider == true)); @@ -534,14 +534,14 @@ public class PoliciesControllerTests .Returns(policy); // Act - var result = await sutProvider.Sut.PutVNext(orgId, model); + var result = await sutProvider.Sut.PutVNext(orgId, policy.Type, model); // Assert await sutProvider.GetDependency() .Received(1) .VNextSaveAsync(Arg.Is( m => m.PolicyUpdate.OrganizationId == orgId && - m.PolicyUpdate.Type == model.Policy.Type && + m.PolicyUpdate.Type == policy.Type && m.PolicyUpdate.Enabled == model.Policy.Enabled && m.PerformedBy.UserId == userId && m.PerformedBy.IsOrganizationOwnerOrProvider == true)); From b2543b5c0f43f0fe2c9c29b7c5953c76ddb1f834 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Mon, 10 Nov 2025 11:51:00 -0600 Subject: [PATCH 11/77] [PM-24284] - milestone 3 (#6543) * new feature flag * first pass at changes * safeguard against billing-pricing not being deployed yet * handle families pre migration plan * wrong stripe id * tests * unit tests --- .../AdminConsole/Services/ProviderService.cs | 5 +- .../Controllers/FreshsalesController.cs | 1 + src/Core/Billing/Enums/PlanType.cs | 6 +- .../Billing/Extensions/BillingExtensions.cs | 2 +- .../StaticStore/Plans/Families2025Plan.cs | 47 ++ .../Models/StaticStore/Plans/FamiliesPlan.cs | 6 +- .../Pricing/Organizations/PlanAdapter.cs | 3 +- src/Core/Billing/Pricing/PricingClient.cs | 24 +- src/Core/Constants.cs | 1 + .../Models/Business/SubscriptionUpdate.cs | 1 + src/Core/Utilities/StaticStore.cs | 1 + .../Repositories/OrganizationRepository.cs | 1 + .../ConfirmOrganizationUserCommandTests.cs | 2 + .../CloudOrganizationSignUpCommandTests.cs | 2 + .../Billing/Pricing/PricingClientTests.cs | 527 ++++++++++++++++++ .../SecretsManagerSubscriptionUpdateTests.cs | 2 +- test/Core.Test/Utilities/StaticStoreTests.cs | 6 +- 17 files changed, 621 insertions(+), 16 deletions(-) create mode 100644 src/Core/Billing/Models/StaticStore/Plans/Families2025Plan.cs create mode 100644 test/Core.Test/Billing/Pricing/PricingClientTests.cs diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs index aaf0050b63..89ef251fd6 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs @@ -35,8 +35,9 @@ public class ProviderService : IProviderService { private static readonly PlanType[] _resellerDisallowedOrganizationTypes = [ PlanType.Free, - PlanType.FamiliesAnnually, - PlanType.FamiliesAnnually2019 + PlanType.FamiliesAnnually2025, + PlanType.FamiliesAnnually2019, + PlanType.FamiliesAnnually ]; private readonly IDataProtector _dataProtector; diff --git a/src/Billing/Controllers/FreshsalesController.cs b/src/Billing/Controllers/FreshsalesController.cs index be5a9ddb16..68382fbd5d 100644 --- a/src/Billing/Controllers/FreshsalesController.cs +++ b/src/Billing/Controllers/FreshsalesController.cs @@ -158,6 +158,7 @@ public class FreshsalesController : Controller planName = "Free"; return true; case PlanType.FamiliesAnnually: + case PlanType.FamiliesAnnually2025: case PlanType.FamiliesAnnually2019: planName = "Families"; return true; diff --git a/src/Core/Billing/Enums/PlanType.cs b/src/Core/Billing/Enums/PlanType.cs index e88a73af16..0f910c4980 100644 --- a/src/Core/Billing/Enums/PlanType.cs +++ b/src/Core/Billing/Enums/PlanType.cs @@ -18,8 +18,8 @@ public enum PlanType : byte EnterpriseAnnually2019 = 5, [Display(Name = "Custom")] Custom = 6, - [Display(Name = "Families")] - FamiliesAnnually = 7, + [Display(Name = "Families 2025")] + FamiliesAnnually2025 = 7, [Display(Name = "Teams (Monthly) 2020")] TeamsMonthly2020 = 8, [Display(Name = "Teams (Annually) 2020")] @@ -48,4 +48,6 @@ public enum PlanType : byte EnterpriseAnnually = 20, [Display(Name = "Teams Starter")] TeamsStarter = 21, + [Display(Name = "Families")] + FamiliesAnnually = 22, } diff --git a/src/Core/Billing/Extensions/BillingExtensions.cs b/src/Core/Billing/Extensions/BillingExtensions.cs index 7f81bfd33f..2dae0c2025 100644 --- a/src/Core/Billing/Extensions/BillingExtensions.cs +++ b/src/Core/Billing/Extensions/BillingExtensions.cs @@ -15,7 +15,7 @@ public static class BillingExtensions => planType switch { PlanType.Custom or PlanType.Free => ProductTierType.Free, - PlanType.FamiliesAnnually or PlanType.FamiliesAnnually2019 => ProductTierType.Families, + PlanType.FamiliesAnnually or PlanType.FamiliesAnnually2025 or PlanType.FamiliesAnnually2019 => ProductTierType.Families, PlanType.TeamsStarter or PlanType.TeamsStarter2023 => ProductTierType.TeamsStarter, _ when planType.ToString().Contains("Teams") => ProductTierType.Teams, _ when planType.ToString().Contains("Enterprise") => ProductTierType.Enterprise, diff --git a/src/Core/Billing/Models/StaticStore/Plans/Families2025Plan.cs b/src/Core/Billing/Models/StaticStore/Plans/Families2025Plan.cs new file mode 100644 index 0000000000..77e238e98e --- /dev/null +++ b/src/Core/Billing/Models/StaticStore/Plans/Families2025Plan.cs @@ -0,0 +1,47 @@ +using Bit.Core.Billing.Enums; +using Bit.Core.Models.StaticStore; + +namespace Bit.Core.Billing.Models.StaticStore.Plans; + +public record Families2025Plan : Plan +{ + public Families2025Plan() + { + Type = PlanType.FamiliesAnnually2025; + ProductTier = ProductTierType.Families; + Name = "Families 2025"; + IsAnnual = true; + NameLocalizationKey = "planNameFamilies"; + DescriptionLocalizationKey = "planDescFamilies"; + + TrialPeriodDays = 7; + + HasSelfHost = true; + HasTotp = true; + UsersGetPremium = true; + + UpgradeSortOrder = 1; + DisplaySortOrder = 1; + + PasswordManager = new Families2025PasswordManagerFeatures(); + } + + private record Families2025PasswordManagerFeatures : PasswordManagerPlanFeatures + { + public Families2025PasswordManagerFeatures() + { + BaseSeats = 6; + BaseStorageGb = 1; + MaxSeats = 6; + + HasAdditionalStorageOption = true; + + StripePlanId = "2020-families-org-annually"; + StripeStoragePlanId = "personal-storage-gb-annually"; + BasePrice = 40; + AdditionalStoragePricePerGb = 4; + + AllowSeatAutoscale = false; + } + } +} diff --git a/src/Core/Billing/Models/StaticStore/Plans/FamiliesPlan.cs b/src/Core/Billing/Models/StaticStore/Plans/FamiliesPlan.cs index 8c71e50fa4..b2edc1168b 100644 --- a/src/Core/Billing/Models/StaticStore/Plans/FamiliesPlan.cs +++ b/src/Core/Billing/Models/StaticStore/Plans/FamiliesPlan.cs @@ -23,12 +23,12 @@ public record FamiliesPlan : Plan UpgradeSortOrder = 1; DisplaySortOrder = 1; - PasswordManager = new TeamsPasswordManagerFeatures(); + PasswordManager = new FamiliesPasswordManagerFeatures(); } - private record TeamsPasswordManagerFeatures : PasswordManagerPlanFeatures + private record FamiliesPasswordManagerFeatures : PasswordManagerPlanFeatures { - public TeamsPasswordManagerFeatures() + public FamiliesPasswordManagerFeatures() { BaseSeats = 6; BaseStorageGb = 1; diff --git a/src/Core/Billing/Pricing/Organizations/PlanAdapter.cs b/src/Core/Billing/Pricing/Organizations/PlanAdapter.cs index 390f7b2146..ac60411366 100644 --- a/src/Core/Billing/Pricing/Organizations/PlanAdapter.cs +++ b/src/Core/Billing/Pricing/Organizations/PlanAdapter.cs @@ -58,6 +58,7 @@ public record PlanAdapter : Core.Models.StaticStore.Plan "enterprise-monthly-2020" => PlanType.EnterpriseMonthly2020, "enterprise-monthly-2023" => PlanType.EnterpriseMonthly2023, "families" => PlanType.FamiliesAnnually, + "families-2025" => PlanType.FamiliesAnnually2025, "families-2019" => PlanType.FamiliesAnnually2019, "free" => PlanType.Free, "teams-annually" => PlanType.TeamsAnnually, @@ -77,7 +78,7 @@ public record PlanAdapter : Core.Models.StaticStore.Plan => planType switch { PlanType.Free => ProductTierType.Free, - PlanType.FamiliesAnnually or PlanType.FamiliesAnnually2019 => ProductTierType.Families, + PlanType.FamiliesAnnually or PlanType.FamiliesAnnually2025 or PlanType.FamiliesAnnually2019 => ProductTierType.Families, PlanType.TeamsStarter or PlanType.TeamsStarter2023 => ProductTierType.TeamsStarter, _ when planType.ToString().Contains("Teams") => ProductTierType.Teams, _ when planType.ToString().Contains("Enterprise") => ProductTierType.Enterprise, diff --git a/src/Core/Billing/Pricing/PricingClient.cs b/src/Core/Billing/Pricing/PricingClient.cs index 21863d03e8..0c4266665a 100644 --- a/src/Core/Billing/Pricing/PricingClient.cs +++ b/src/Core/Billing/Pricing/PricingClient.cs @@ -50,7 +50,7 @@ public class PricingClient( var plan = await response.Content.ReadFromJsonAsync(); return plan == null ? throw new BillingException(message: "Deserialization of Pricing Service response resulted in null") - : new PlanAdapter(plan); + : new PlanAdapter(PreProcessFamiliesPreMigrationPlan(plan)); } if (response.StatusCode == HttpStatusCode.NotFound) @@ -91,7 +91,7 @@ public class PricingClient( var plans = await response.Content.ReadFromJsonAsync>(); return plans == null ? throw new BillingException(message: "Deserialization of Pricing Service response resulted in null") - : plans.Select(OrganizationPlan (plan) => new PlanAdapter(plan)).ToList(); + : plans.Select(OrganizationPlan (plan) => new PlanAdapter(PreProcessFamiliesPreMigrationPlan(plan))).ToList(); } throw new BillingException( @@ -137,7 +137,7 @@ public class PricingClient( message: $"Request to the Pricing Service failed with status {response.StatusCode}"); } - private static string? GetLookupKey(PlanType planType) + private string? GetLookupKey(PlanType planType) => planType switch { PlanType.EnterpriseAnnually => "enterprise-annually", @@ -149,6 +149,10 @@ public class PricingClient( PlanType.EnterpriseMonthly2020 => "enterprise-monthly-2020", PlanType.EnterpriseMonthly2023 => "enterprise-monthly-2023", PlanType.FamiliesAnnually => "families", + PlanType.FamiliesAnnually2025 => + featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3) + ? "families-2025" + : "families", PlanType.FamiliesAnnually2019 => "families-2019", PlanType.Free => "free", PlanType.TeamsAnnually => "teams-annually", @@ -164,6 +168,20 @@ public class PricingClient( _ => null }; + /// + /// Safeguard used until the feature flag is enabled. Pricing service will return the + /// 2025PreMigration plan with "families" lookup key. When that is detected and the FF + /// is still disabled, set the lookup key to families-2025 so PlanAdapter will assign + /// the correct plan. + /// + /// The plan to preprocess + private Plan PreProcessFamiliesPreMigrationPlan(Plan plan) + { + if (plan.LookupKey == "families" && !featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3)) + plan.LookupKey = "families-2025"; + return plan; + } + private static PremiumPlan CurrentPremiumPlan => new() { Name = "Premium", diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index eaa8f9163a..c5b6bbc10d 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -195,6 +195,7 @@ public static class FeatureFlagKeys public const string PremiumUpgradeNewDesign = "pm-24033-updat-premium-subscription-page"; 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"; /* Key Management Team */ public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; diff --git a/src/Core/Models/Business/SubscriptionUpdate.cs b/src/Core/Models/Business/SubscriptionUpdate.cs index 028fcad80b..7c23c9b73c 100644 --- a/src/Core/Models/Business/SubscriptionUpdate.cs +++ b/src/Core/Models/Business/SubscriptionUpdate.cs @@ -50,6 +50,7 @@ public abstract class SubscriptionUpdate protected static bool IsNonSeatBasedPlan(StaticStore.Plan plan) => plan.Type is >= PlanType.FamiliesAnnually2019 and <= PlanType.EnterpriseAnnually2019 + or PlanType.FamiliesAnnually2025 or PlanType.FamiliesAnnually or PlanType.TeamsStarter2023 or PlanType.TeamsStarter; diff --git a/src/Core/Utilities/StaticStore.cs b/src/Core/Utilities/StaticStore.cs index 1ddd926569..36c4a54ae4 100644 --- a/src/Core/Utilities/StaticStore.cs +++ b/src/Core/Utilities/StaticStore.cs @@ -137,6 +137,7 @@ public static class StaticStore new Teams2019Plan(true), new Teams2019Plan(false), new Families2019Plan(), + new Families2025Plan() }.ToImmutableList(); } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs index 2238bfca76..ebc2bc6606 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs @@ -129,6 +129,7 @@ public class OrganizationRepository : Repository sutProvider) { signup.Plan = planType; @@ -65,6 +66,7 @@ public class CloudICloudOrganizationSignUpCommandTests [Theory] [BitAutoData(PlanType.FamiliesAnnually)] + [BitAutoData(PlanType.FamiliesAnnually2025)] public async Task SignUp_AssignsOwnerToDefaultCollection (PlanType planType, OrganizationSignup signup, SutProvider sutProvider) { diff --git a/test/Core.Test/Billing/Pricing/PricingClientTests.cs b/test/Core.Test/Billing/Pricing/PricingClientTests.cs new file mode 100644 index 0000000000..189df15b9c --- /dev/null +++ b/test/Core.Test/Billing/Pricing/PricingClientTests.cs @@ -0,0 +1,527 @@ +using System.Net; +using Bit.Core.Billing; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Pricing; +using Bit.Core.Services; +using Bit.Core.Utilities; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Logging; +using NSubstitute; +using RichardSzalay.MockHttp; +using Xunit; +using GlobalSettings = Bit.Core.Settings.GlobalSettings; + +namespace Bit.Core.Test.Billing.Pricing; + +[SutProviderCustomize] +public class PricingClientTests +{ + #region GetLookupKey Tests (via GetPlan) + + [Fact] + public async Task GetPlan_WithFamiliesAnnually2025AndFeatureFlagEnabled_UsesFamilies2025LookupKey() + { + // Arrange + var mockHttp = new MockHttpMessageHandler(); + var planJson = CreatePlanJson("families-2025", "Families 2025", "families", 40M, "price_id"); + + mockHttp.Expect(HttpMethod.Get, "https://test.com/plans/organization/families-2025") + .Respond("application/json", planJson); + + mockHttp.When(HttpMethod.Get, "*/plans/organization/*") + .Respond("application/json", planJson); + + var featureService = Substitute.For(); + featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true); + + var globalSettings = new GlobalSettings { SelfHosted = false }; + + var httpClient = new HttpClient(mockHttp) + { + BaseAddress = new Uri("https://test.com/") + }; + + var logger = Substitute.For>(); + var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger); + + // Act + var result = await pricingClient.GetPlan(PlanType.FamiliesAnnually2025); + + // Assert + Assert.NotNull(result); + Assert.Equal(PlanType.FamiliesAnnually2025, result.Type); + mockHttp.VerifyNoOutstandingExpectation(); + } + + [Fact] + public async Task GetPlan_WithFamiliesAnnually2025AndFeatureFlagDisabled_UsesFamiliesLookupKey() + { + // Arrange + var mockHttp = new MockHttpMessageHandler(); + var planJson = CreatePlanJson("families", "Families", "families", 40M, "price_id"); + + mockHttp.Expect(HttpMethod.Get, "https://test.com/plans/organization/families") + .Respond("application/json", planJson); + + mockHttp.When(HttpMethod.Get, "*/plans/organization/*") + .Respond("application/json", planJson); + + var featureService = Substitute.For(); + featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(false); + featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true); + + var globalSettings = new GlobalSettings { SelfHosted = false }; + + var httpClient = new HttpClient(mockHttp) + { + BaseAddress = new Uri("https://test.com/") + }; + + var logger = Substitute.For>(); + var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger); + + // Act + var result = await pricingClient.GetPlan(PlanType.FamiliesAnnually2025); + + // Assert + Assert.NotNull(result); + // PreProcessFamiliesPreMigrationPlan should change "families" to "families-2025" when FF is disabled + Assert.Equal(PlanType.FamiliesAnnually2025, result.Type); + mockHttp.VerifyNoOutstandingExpectation(); + } + + #endregion + + #region PreProcessFamiliesPreMigrationPlan Tests (via GetPlan) + + [Fact] + public async Task GetPlan_WithFamiliesAnnually2025AndFeatureFlagDisabled_ReturnsFamiliesAnnually2025PlanType() + { + // Arrange + var mockHttp = new MockHttpMessageHandler(); + // billing-pricing returns "families" lookup key because the flag is off + var planJson = CreatePlanJson("families", "Families", "families", 40M, "price_id"); + + mockHttp.When(HttpMethod.Get, "*/plans/organization/*") + .Respond("application/json", planJson); + + var featureService = Substitute.For(); + featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(false); + featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true); + + var globalSettings = new GlobalSettings { SelfHosted = false }; + + var httpClient = new HttpClient(mockHttp) + { + BaseAddress = new Uri("https://test.com/") + }; + + var logger = Substitute.For>(); + var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger); + + // Act + var result = await pricingClient.GetPlan(PlanType.FamiliesAnnually2025); + + // Assert + Assert.NotNull(result); + // PreProcessFamiliesPreMigrationPlan should convert the families lookup key to families-2025 + // and the PlanAdapter should assign the correct FamiliesAnnually2025 plan type + Assert.Equal(PlanType.FamiliesAnnually2025, result.Type); + mockHttp.VerifyNoOutstandingExpectation(); + } + + [Fact] + public async Task GetPlan_WithFamiliesAnnually2025AndFeatureFlagEnabled_ReturnsFamiliesAnnually2025PlanType() + { + // Arrange + var mockHttp = new MockHttpMessageHandler(); + var planJson = CreatePlanJson("families-2025", "Families", "families", 40M, "price_id"); + + mockHttp.When(HttpMethod.Get, "*/plans/organization/*") + .Respond("application/json", planJson); + + var featureService = Substitute.For(); + featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true); + + var globalSettings = new GlobalSettings { SelfHosted = false }; + + var httpClient = new HttpClient(mockHttp) + { + BaseAddress = new Uri("https://test.com/") + }; + + var logger = Substitute.For>(); + var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger); + + // Act + var result = await pricingClient.GetPlan(PlanType.FamiliesAnnually2025); + + // Assert + Assert.NotNull(result); + // PreProcessFamiliesPreMigrationPlan should ignore the lookup key because the flag is on + // and the PlanAdapter should assign the correct FamiliesAnnually2025 plan type + Assert.Equal(PlanType.FamiliesAnnually2025, result.Type); + mockHttp.VerifyNoOutstandingExpectation(); + } + + [Fact] + public async Task GetPlan_WithFamiliesAnnuallyAndFeatureFlagEnabled_ReturnsFamiliesAnnuallyPlanType() + { + // Arrange + var mockHttp = new MockHttpMessageHandler(); + var planJson = CreatePlanJson("families", "Families", "families", 40M, "price_id"); + + mockHttp.When(HttpMethod.Get, "*/plans/organization/*") + .Respond("application/json", planJson); + + var featureService = Substitute.For(); + featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true); + + var globalSettings = new GlobalSettings { SelfHosted = false }; + + var httpClient = new HttpClient(mockHttp) + { + BaseAddress = new Uri("https://test.com/") + }; + + var logger = Substitute.For>(); + var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger); + + // Act + var result = await pricingClient.GetPlan(PlanType.FamiliesAnnually); + + // Assert + Assert.NotNull(result); + // PreProcessFamiliesPreMigrationPlan should ignore the lookup key because the flag is on + // and the PlanAdapter should assign the correct FamiliesAnnually plan type + Assert.Equal(PlanType.FamiliesAnnually, result.Type); + mockHttp.VerifyNoOutstandingExpectation(); + } + + [Fact] + public async Task GetPlan_WithOtherLookupKey_KeepsLookupKeyUnchanged() + { + // Arrange + var mockHttp = new MockHttpMessageHandler(); + var planJson = CreatePlanJson("enterprise-annually", "Enterprise", "enterprise", 144M, "price_id"); + + mockHttp.Expect(HttpMethod.Get, "https://test.com/plans/organization/enterprise-annually") + .Respond("application/json", planJson); + + mockHttp.When(HttpMethod.Get, "*/plans/organization/*") + .Respond("application/json", planJson); + + var featureService = Substitute.For(); + featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(false); + featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true); + + var globalSettings = new GlobalSettings { SelfHosted = false }; + + var httpClient = new HttpClient(mockHttp) + { + BaseAddress = new Uri("https://test.com/") + }; + + var logger = Substitute.For>(); + var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger); + + // Act + var result = await pricingClient.GetPlan(PlanType.EnterpriseAnnually); + + // Assert + Assert.NotNull(result); + Assert.Equal(PlanType.EnterpriseAnnually, result.Type); + mockHttp.VerifyNoOutstandingExpectation(); + } + + #endregion + + #region ListPlans Tests + + [Fact] + public async Task ListPlans_WithFeatureFlagDisabled_ReturnsListWithPreProcessing() + { + // Arrange + var mockHttp = new MockHttpMessageHandler(); + // biling-pricing would return "families" because the flag is disabled + var plansJson = $@"[ + {CreatePlanJson("families", "Families", "families", 40M, "price_id")}, + {CreatePlanJson("enterprise-annually", "Enterprise", "enterprise", 144M, "price_id")} + ]"; + + mockHttp.When(HttpMethod.Get, "*/plans/organization") + .Respond("application/json", plansJson); + + var featureService = Substitute.For(); + featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(false); + featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true); + + var globalSettings = new GlobalSettings { SelfHosted = false }; + + var httpClient = new HttpClient(mockHttp) + { + BaseAddress = new Uri("https://test.com/") + }; + + var logger = Substitute.For>(); + var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger); + + // Act + var result = await pricingClient.ListPlans(); + + // Assert + Assert.NotNull(result); + Assert.Equal(2, result.Count); + // First plan should have been preprocessed from "families" to "families-2025" + Assert.Equal(PlanType.FamiliesAnnually2025, result[0].Type); + // Second plan should remain unchanged + Assert.Equal(PlanType.EnterpriseAnnually, result[1].Type); + mockHttp.VerifyNoOutstandingExpectation(); + } + + [Fact] + public async Task ListPlans_WithFeatureFlagEnabled_ReturnsListWithoutPreProcessing() + { + // Arrange + var mockHttp = new MockHttpMessageHandler(); + var plansJson = $@"[ + {CreatePlanJson("families", "Families", "families", 40M, "price_id")} + ]"; + + mockHttp.When(HttpMethod.Get, "*/plans/organization") + .Respond("application/json", plansJson); + + var featureService = Substitute.For(); + featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true); + + var globalSettings = new GlobalSettings { SelfHosted = false }; + + var httpClient = new HttpClient(mockHttp) + { + BaseAddress = new Uri("https://test.com/") + }; + + var logger = Substitute.For>(); + var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger); + + // Act + var result = await pricingClient.ListPlans(); + + // Assert + Assert.NotNull(result); + Assert.Single(result); + // Plan should remain as FamiliesAnnually when FF is enabled + Assert.Equal(PlanType.FamiliesAnnually, result[0].Type); + mockHttp.VerifyNoOutstandingExpectation(); + } + + #endregion + + #region GetPlan - Additional Coverage + + [Theory, BitAutoData] + public async Task GetPlan_WhenSelfHosted_ReturnsNull( + SutProvider sutProvider) + { + // Arrange + var globalSettings = sutProvider.GetDependency(); + globalSettings.SelfHosted = true; + + // Act + var result = await sutProvider.Sut.GetPlan(PlanType.FamiliesAnnually2025); + + // Assert + Assert.Null(result); + } + + [Theory, BitAutoData] + public async Task GetPlan_WhenPricingServiceDisabled_ReturnsStaticStorePlan( + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency().SelfHosted = false; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.UsePricingService) + .Returns(false); + + // Act + var result = await sutProvider.Sut.GetPlan(PlanType.FamiliesAnnually); + + // Assert + Assert.NotNull(result); + Assert.Equal(PlanType.FamiliesAnnually, result.Type); + } + + [Theory, BitAutoData] + public async Task GetPlan_WhenLookupKeyNotFound_ReturnsNull( + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.UsePricingService) + .Returns(true); + + // Act - Using PlanType that doesn't have a lookup key mapping + var result = await sutProvider.Sut.GetPlan(unchecked((PlanType)999)); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task GetPlan_WhenPricingServiceReturnsNotFound_ReturnsNull() + { + // Arrange + var mockHttp = new MockHttpMessageHandler(); + mockHttp.When(HttpMethod.Get, "*/plans/organization/*") + .Respond(HttpStatusCode.NotFound); + + var featureService = Substitute.For(); + featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true); + + var globalSettings = new GlobalSettings { SelfHosted = false }; + + var httpClient = new HttpClient(mockHttp) + { + BaseAddress = new Uri("https://test.com/") + }; + + var logger = Substitute.For>(); + var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger); + + // Act + var result = await pricingClient.GetPlan(PlanType.FamiliesAnnually2025); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task GetPlan_WhenPricingServiceReturnsError_ThrowsBillingException() + { + // Arrange + var mockHttp = new MockHttpMessageHandler(); + mockHttp.When(HttpMethod.Get, "*/plans/organization/*") + .Respond(HttpStatusCode.InternalServerError); + + var featureService = Substitute.For(); + featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true); + + var globalSettings = new GlobalSettings { SelfHosted = false }; + + var httpClient = new HttpClient(mockHttp) + { + BaseAddress = new Uri("https://test.com/") + }; + + var logger = Substitute.For>(); + var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger); + + // Act & Assert + await Assert.ThrowsAsync(() => + pricingClient.GetPlan(PlanType.FamiliesAnnually2025)); + } + + #endregion + + #region ListPlans - Additional Coverage + + [Theory, BitAutoData] + public async Task ListPlans_WhenSelfHosted_ReturnsEmptyList( + SutProvider sutProvider) + { + // Arrange + var globalSettings = sutProvider.GetDependency(); + globalSettings.SelfHosted = true; + + // Act + var result = await sutProvider.Sut.ListPlans(); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Theory, BitAutoData] + public async Task ListPlans_WhenPricingServiceDisabled_ReturnsStaticStorePlans( + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency().SelfHosted = false; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.UsePricingService) + .Returns(false); + + // Act + var result = await sutProvider.Sut.ListPlans(); + + // Assert + Assert.NotNull(result); + Assert.NotEmpty(result); + Assert.Equal(StaticStore.Plans.Count(), result.Count); + } + + [Fact] + public async Task ListPlans_WhenPricingServiceReturnsError_ThrowsBillingException() + { + // Arrange + var mockHttp = new MockHttpMessageHandler(); + mockHttp.When(HttpMethod.Get, "*/plans/organization") + .Respond(HttpStatusCode.InternalServerError); + + var featureService = Substitute.For(); + featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true); + + var globalSettings = new GlobalSettings { SelfHosted = false }; + + var httpClient = new HttpClient(mockHttp) + { + BaseAddress = new Uri("https://test.com/") + }; + + var logger = Substitute.For>(); + var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger); + + // Act & Assert + await Assert.ThrowsAsync(() => + pricingClient.ListPlans()); + } + + #endregion + + private static string CreatePlanJson( + string lookupKey, + string name, + string tier, + decimal seatsPrice, + string seatsStripePriceId, + int seatsQuantity = 1) + { + return $@"{{ + ""lookupKey"": ""{lookupKey}"", + ""name"": ""{name}"", + ""tier"": ""{tier}"", + ""features"": [], + ""seats"": {{ + ""type"": ""packaged"", + ""quantity"": {seatsQuantity}, + ""price"": {seatsPrice}, + ""stripePriceId"": ""{seatsStripePriceId}"" + }}, + ""canUpgradeTo"": [], + ""additionalData"": {{ + ""nameLocalizationKey"": ""{lookupKey}Name"", + ""descriptionLocalizationKey"": ""{lookupKey}Description"" + }} + }}"; + } +} diff --git a/test/Core.Test/Models/Business/SecretsManagerSubscriptionUpdateTests.cs b/test/Core.Test/Models/Business/SecretsManagerSubscriptionUpdateTests.cs index 6a411363a0..20405b07b0 100644 --- a/test/Core.Test/Models/Business/SecretsManagerSubscriptionUpdateTests.cs +++ b/test/Core.Test/Models/Business/SecretsManagerSubscriptionUpdateTests.cs @@ -22,7 +22,7 @@ public class SecretsManagerSubscriptionUpdateTests } public static TheoryData NonSmPlans => - ToPlanTheory([PlanType.Custom, PlanType.FamiliesAnnually, PlanType.FamiliesAnnually2019]); + ToPlanTheory([PlanType.Custom, PlanType.FamiliesAnnually, PlanType.FamiliesAnnually2025, PlanType.FamiliesAnnually2019]); public static TheoryData SmPlans => ToPlanTheory([ PlanType.EnterpriseAnnually2019, diff --git a/test/Core.Test/Utilities/StaticStoreTests.cs b/test/Core.Test/Utilities/StaticStoreTests.cs index 05c6d358e5..01e2ab8914 100644 --- a/test/Core.Test/Utilities/StaticStoreTests.cs +++ b/test/Core.Test/Utilities/StaticStoreTests.cs @@ -13,7 +13,7 @@ public class StaticStoreTests var plans = StaticStore.Plans.ToList(); Assert.NotNull(plans); Assert.NotEmpty(plans); - Assert.Equal(22, plans.Count); + Assert.Equal(23, plans.Count); } [Theory] @@ -34,8 +34,8 @@ public class StaticStoreTests { // Ref: https://daniel.haxx.se/blog/2025/05/16/detecting-malicious-unicode/ // URLs can contain unicode characters that to a computer would point to completely seperate domains but to the - // naked eye look completely identical. For example 'g' and 'ց' look incredibly similar but when included in a - // URL would lead you somewhere different. There is an opening for an attacker to contribute to Bitwarden with a + // naked eye look completely identical. For example 'g' and 'ց' look incredibly similar but when included in a + // URL would lead you somewhere different. There is an opening for an attacker to contribute to Bitwarden with a // url update that could be missed in code review and then if they got a user to that URL Bitwarden could // consider it equivalent with a cipher in the users vault and offer autofill when we should not. // GitHub does now show a warning on non-ascii characters but it could still be missed. From 746b413cff692c819c08a018d6790b337acfe1cc Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Mon, 10 Nov 2025 13:37:11 -0500 Subject: [PATCH 12/77] [PM-21741] MJML welcome emails (#6549) feat: Implement welcome email using MJML templating - Implement MJML templates for welcome emails (individual, family, org) - Create reusable MJML components (mj-bw-icon-row, mj-bw-learn-more-footer) - Update documentation for MJML development process --- .../Auth/SendAccessEmailOtpEmailv2.html.hbs | 383 +++++++++--------- src/Core/MailTemplates/Mjml/.mjmlconfig | 4 +- src/Core/MailTemplates/Mjml/README.md | 17 +- .../MailTemplates/Mjml/components/head.mjml | 6 - .../Mjml/components/learn-more-footer.mjml | 18 - .../Mjml/components/mj-bw-hero.js | 76 ++-- .../Mjml/components/mj-bw-icon-row.js | 100 +++++ .../components/mj-bw-learn-more-footer.js | 51 +++ .../Auth/Onboarding/welcome-family-user.mjml | 60 +++ .../Auth/Onboarding/welcome-free-user.mjml | 59 +++ .../Auth/Onboarding/welcome-org-user.mjml | 60 +++ .../Mjml/emails/Auth/send-email-otp.mjml | 2 +- .../Mjml/emails/Auth/two-factor.mjml | 4 +- .../MailTemplates/Mjml/emails/invite.mjml | 2 +- 14 files changed, 580 insertions(+), 262 deletions(-) delete mode 100644 src/Core/MailTemplates/Mjml/components/learn-more-footer.mjml create mode 100644 src/Core/MailTemplates/Mjml/components/mj-bw-icon-row.js create mode 100644 src/Core/MailTemplates/Mjml/components/mj-bw-learn-more-footer.js create mode 100644 src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/welcome-family-user.mjml create mode 100644 src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/welcome-free-user.mjml create mode 100644 src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/welcome-org-user.mjml diff --git a/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs index 095cdc82d7..fad0af840d 100644 --- a/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs +++ b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs @@ -29,8 +29,8 @@ .mj-outlook-group-fix { width:100% !important; } - - + + - - - - + + + + - + - - + +
- + - - + +
- +
- +
- - + + - - + +
- +
- +
- + - + - + - +
- +
- + - +
- +
- +

Verify your email to access this Bitwarden Send

- +
- +
- + - +
- + - + - +
- + -
- - - + + + +
- +
- +
- +
- +
- - + + - - + +
- +
- +
- - + + - + - + - - + +
- +
- - + +
- +
- +
- +
- + - + - + - + - + - +
- +
Your verification code is:
- +
- +
{{Token}}
- +
- +
- +
- +
This code expires in {{Expiry}} minutes. After that, you'll need to verify your email again.
- +
- +
- +
- +
- +
- - + + - - + +
- +
- +
- +
- + - + - +
- +

Bitwarden Send transmits sensitive, temporary information to others easily and securely. Learn more about @@ -317,160 +325,160 @@ sign up to try it today.

- +
- +
- +
- +
- +
- - + +
- +
- - + + - + - + - - + +
- +
- - + +
- + -
- - + + +
- + - + - +
- -

+ +

Learn more about Bitwarden -

+

Find user guides, product documentation, and videos on the Bitwarden Help Center.
- +
- +
- - - + + +
- + - + - +
- +
- + - +
- +
- +
- +
- +
- - + +
- +
- - + + - + - + - - + +
- +
- +
- + - + - + - +
- - + + - + - + - +
@@ -485,15 +493,15 @@
- + - + - +
@@ -508,15 +516,15 @@
- + - + - +
@@ -531,15 +539,15 @@
- + - + - +
@@ -554,15 +562,15 @@
- + - + - +
@@ -577,15 +585,15 @@
- + - + - +
@@ -600,15 +608,15 @@
- + - + - +
@@ -623,20 +631,20 @@
- - + +
- +

© 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa Barbara, CA, USA @@ -647,29 +655,28 @@ bitwarden.com | Learn why we include this

- +
- +
- +
- +
- - + + - - + +
- + - \ No newline at end of file diff --git a/src/Core/MailTemplates/Mjml/.mjmlconfig b/src/Core/MailTemplates/Mjml/.mjmlconfig index c382f10a12..92734a5f71 100644 --- a/src/Core/MailTemplates/Mjml/.mjmlconfig +++ b/src/Core/MailTemplates/Mjml/.mjmlconfig @@ -1,5 +1,7 @@ { "packages": [ - "components/mj-bw-hero" + "components/mj-bw-hero", + "components/mj-bw-icon-row", + "components/mj-bw-learn-more-footer" ] } diff --git a/src/Core/MailTemplates/Mjml/README.md b/src/Core/MailTemplates/Mjml/README.md index 7a497252d0..b9041c94f6 100644 --- a/src/Core/MailTemplates/Mjml/README.md +++ b/src/Core/MailTemplates/Mjml/README.md @@ -45,7 +45,7 @@ When using MJML templating you can use the above [commands](#building-mjml-files 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 +### Recommended development - IMailService #### Mjml email template development @@ -58,11 +58,17 @@ Not all MJML tags have the same attributes, it is highly recommended to review t 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:minify` +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/Handlebars/` directories in order to be used by `IMailService` implementations. +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 @@ -110,3 +116,8 @@ 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. + +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/head.mjml b/src/Core/MailTemplates/Mjml/components/head.mjml index cf78cd6223..4cb27889eb 100644 --- a/src/Core/MailTemplates/Mjml/components/head.mjml +++ b/src/Core/MailTemplates/Mjml/components/head.mjml @@ -22,9 +22,3 @@ border-radius: 3px; } - - - -@media only screen and - (max-width: 480px) { .hide-small-img { display: none !important; } .send-bubble { padding-left: 20px; padding-right: 20px; width: 90% !important; } } - diff --git a/src/Core/MailTemplates/Mjml/components/learn-more-footer.mjml b/src/Core/MailTemplates/Mjml/components/learn-more-footer.mjml deleted file mode 100644 index 9df0614aae..0000000000 --- a/src/Core/MailTemplates/Mjml/components/learn-more-footer.mjml +++ /dev/null @@ -1,18 +0,0 @@ - - - -

- Learn more about Bitwarden -

- Find user guides, product documentation, and videos on the - Bitwarden Help Center. -
-
- - - -
diff --git a/src/Core/MailTemplates/Mjml/components/mj-bw-hero.js b/src/Core/MailTemplates/Mjml/components/mj-bw-hero.js index d329d4ea38..c7a3b7e7ff 100644 --- a/src/Core/MailTemplates/Mjml/components/mj-bw-hero.js +++ b/src/Core/MailTemplates/Mjml/components/mj-bw-hero.js @@ -18,27 +18,19 @@ class MjBwHero extends BodyComponent { static defaultAttributes = {}; + componentHeadStyle = breakpoint => { + return ` + @media only screen and (max-width:${breakpoint}) { + .mj-bw-hero-responsive-img { + display: none !important; + } + } + ` + } + render() { - if (this.getAttribute("button-text") && this.getAttribute("button-url")) { - return this.renderMJML(` - - - - -

- ${this.getAttribute("title")} -

-
- ${this.getAttribute("button-text")} -
- - - -
- `); - } else { - return this.renderMJML(` + >` : ""; + const subTitleElement = this.getAttribute("sub-title") ? + ` +

+ ${this.getAttribute("sub-title")} +

+
` : ""; + + return this.renderMJML( + ` ${this.getAttribute("title")} - + ` + + subTitleElement + + ` + ` + + buttonElement + + ` + css-class="mj-bw-hero-responsive-img" + /> - `); - } + `, + ); } } diff --git a/src/Core/MailTemplates/Mjml/components/mj-bw-icon-row.js b/src/Core/MailTemplates/Mjml/components/mj-bw-icon-row.js new file mode 100644 index 0000000000..f7f402c96e --- /dev/null +++ b/src/Core/MailTemplates/Mjml/components/mj-bw-icon-row.js @@ -0,0 +1,100 @@ +const { BodyComponent } = require("mjml-core"); +class MjBwIconRow extends BodyComponent { + static dependencies = { + "mj-column": ["mj-bw-icon-row"], + "mj-wrapper": ["mj-bw-icon-row"], + "mj-bw-icon-row": [], + }; + + static allowedAttributes = { + "icon-src": "string", + "icon-alt": "string", + "head-url-text": "string", + "head-url": "string", + text: "string", + "foot-url-text": "string", + "foot-url": "string", + }; + + static defaultAttributes = {}; + + componentHeadStyle = (breakpoint) => { + return ` + @media only screen and (max-width:${breakpoint}): { + ".mj-bw-icon-row-text": { + padding-left: "5px !important", + line-height: "20px", + }, + ".mj-bw-icon-row": { + padding: "10px 15px", + width: "fit-content !important", + } + } + `; + }; + + render() { + const headAnchorElement = + this.getAttribute("head-url-text") && this.getAttribute("head-url") + ? ` + ${this.getAttribute("head-url-text")} + + External Link Icon + + ` + : ""; + + const footAnchorElement = + this.getAttribute("foot-url-text") && this.getAttribute("foot-url") + ? ` + ${this.getAttribute("foot-url-text")} + + External Link Icon + + ` + : ""; + + return this.renderMJML( + ` + + + + + + + + ` + + headAnchorElement + + ` + + + ${this.getAttribute("text")} + + + ` + + footAnchorElement + + ` + + + + + `, + ); + } +} + +module.exports = MjBwIconRow; diff --git a/src/Core/MailTemplates/Mjml/components/mj-bw-learn-more-footer.js b/src/Core/MailTemplates/Mjml/components/mj-bw-learn-more-footer.js new file mode 100644 index 0000000000..7dc2185995 --- /dev/null +++ b/src/Core/MailTemplates/Mjml/components/mj-bw-learn-more-footer.js @@ -0,0 +1,51 @@ +const { BodyComponent } = require("mjml-core"); +class MjBwLearnMoreFooter extends BodyComponent { + static dependencies = { + // Tell the validator which tags are allowed as our component's parent + "mj-column": ["mj-bw-learn-more-footer"], + "mj-wrapper": ["mj-bw-learn-more-footer"], + // Tell the validator which tags are allowed as our component's children + "mj-bw-learn-more-footer": [], + }; + + static allowedAttributes = {}; + + static defaultAttributes = {}; + + componentHeadStyle = (breakpoint) => { + return ` + @media only screen and (max-width:${breakpoint}) { + .mj-bw-learn-more-footer-responsive-img { + display: none !important; + } + } + `; + }; + + render() { + return this.renderMJML( + ` + + + +

+ Learn more about Bitwarden +

+ Find user guides, product documentation, and videos on the + Bitwarden Help Center. +
+
+ + + +
+ `, + ); + } +} + +module.exports = MjBwLearnMoreFooter; diff --git a/src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/welcome-family-user.mjml b/src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/welcome-family-user.mjml new file mode 100644 index 0000000000..86de49016d --- /dev/null +++ b/src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/welcome-family-user.mjml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + An administrator from {{OrganizationName}} will approve you + before you can share passwords. While you wait for approval, get + started with Bitwarden Password Manager: + + + + + + + + + + + + + + + + + + + diff --git a/src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/welcome-free-user.mjml b/src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/welcome-free-user.mjml new file mode 100644 index 0000000000..e071cd26cc --- /dev/null +++ b/src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/welcome-free-user.mjml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + Follow these simple steps to get up and running with Bitwarden + Password Manager: + + + + + + + + + + + + + + + + + + + diff --git a/src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/welcome-org-user.mjml b/src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/welcome-org-user.mjml new file mode 100644 index 0000000000..39f18fce66 --- /dev/null +++ b/src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/welcome-org-user.mjml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + An administrator from {{OrganizationName}} will need to confirm + you before you can share passwords. Get started with Bitwarden + Password Manager: + + + + + + + + + + + + + + + + + + + diff --git a/src/Core/MailTemplates/Mjml/emails/Auth/send-email-otp.mjml b/src/Core/MailTemplates/Mjml/emails/Auth/send-email-otp.mjml index 6ccc481ff8..d3d4eb9891 100644 --- a/src/Core/MailTemplates/Mjml/emails/Auth/send-email-otp.mjml +++ b/src/Core/MailTemplates/Mjml/emails/Auth/send-email-otp.mjml @@ -55,7 +55,7 @@ - + diff --git a/src/Core/MailTemplates/Mjml/emails/Auth/two-factor.mjml b/src/Core/MailTemplates/Mjml/emails/Auth/two-factor.mjml index 3b63c278fc..73d205ba57 100644 --- a/src/Core/MailTemplates/Mjml/emails/Auth/two-factor.mjml +++ b/src/Core/MailTemplates/Mjml/emails/Auth/two-factor.mjml @@ -24,6 +24,8 @@ - + + + diff --git a/src/Core/MailTemplates/Mjml/emails/invite.mjml b/src/Core/MailTemplates/Mjml/emails/invite.mjml index cdace39c95..8e08a6753a 100644 --- a/src/Core/MailTemplates/Mjml/emails/invite.mjml +++ b/src/Core/MailTemplates/Mjml/emails/invite.mjml @@ -22,7 +22,7 @@ - + From 212f10d22ba7a47d0327c3ef93f15358ae477806 Mon Sep 17 00:00:00 2001 From: Brant DeBow <125889545+brant-livefront@users.noreply.github.com> Date: Mon, 10 Nov 2025 14:55:36 -0500 Subject: [PATCH 13/77] Extend Unit Test Coverage of Event Integrations (#6517) * Extend Unit Test Coverage of Event Integrations * Expanded SlackService error handling and tests * Cleaned up a few issues noted by Claude --- ...onIntegrationConfigurationResponseModel.cs | 4 - .../Models/Slack/SlackApiResponse.cs | 6 + .../AdminConsole/Services/ISlackService.cs | 8 +- .../SlackIntegrationHandler.cs | 33 ++++- .../EventIntegrations/SlackService.cs | 50 +++++-- .../NoopImplementations/NoopSlackService.cs | 8 +- .../OrganizationIntegrationControllerTests.cs | 23 +++ ...ntegrationsConfigurationControllerTests.cs | 129 +++++++++------- .../SlackIntegrationControllerTests.cs | 38 +++++ .../TeamsIntegrationControllerTests.cs | 44 ++++++ ...rganizationIntegrationRequestModelTests.cs | 33 +++++ .../IntegrationTemplateContextTests.cs | 14 ++ .../EventIntegrationEventWriteServiceTests.cs | 14 ++ .../Services/EventIntegrationHandlerTests.cs | 10 ++ .../Services/IntegrationFilterServiceTests.cs | 68 +++++++++ .../Services/SlackIntegrationHandlerTests.cs | 97 ++++++++++++ .../Services/SlackServiceTests.cs | 139 +++++++++++++++++- 17 files changed, 635 insertions(+), 83 deletions(-) diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationConfigurationResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationConfigurationResponseModel.cs index c7906318e8..d070375d88 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationConfigurationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationConfigurationResponseModel.cs @@ -2,8 +2,6 @@ using Bit.Core.Enums; using Bit.Core.Models.Api; -#nullable enable - namespace Bit.Api.AdminConsole.Models.Response.Organizations; public class OrganizationIntegrationConfigurationResponseModel : ResponseModel @@ -11,8 +9,6 @@ public class OrganizationIntegrationConfigurationResponseModel : ResponseModel public OrganizationIntegrationConfigurationResponseModel(OrganizationIntegrationConfiguration organizationIntegrationConfiguration, string obj = "organizationIntegrationConfiguration") : base(obj) { - ArgumentNullException.ThrowIfNull(organizationIntegrationConfiguration); - Id = organizationIntegrationConfiguration.Id; Configuration = organizationIntegrationConfiguration.Configuration; CreationDate = organizationIntegrationConfiguration.CreationDate; diff --git a/src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs b/src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs index 70d280c428..3c811e2b28 100644 --- a/src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs +++ b/src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs @@ -33,6 +33,12 @@ public class SlackOAuthResponse : SlackApiResponse public SlackTeam Team { get; set; } = new(); } +public class SlackSendMessageResponse : SlackApiResponse +{ + [JsonPropertyName("channel")] + public string Channel { get; set; } = string.Empty; +} + public class SlackTeam { public string Id { get; set; } = string.Empty; diff --git a/src/Core/AdminConsole/Services/ISlackService.cs b/src/Core/AdminConsole/Services/ISlackService.cs index 0577532ac2..60d3da8af4 100644 --- a/src/Core/AdminConsole/Services/ISlackService.cs +++ b/src/Core/AdminConsole/Services/ISlackService.cs @@ -1,4 +1,6 @@ -namespace Bit.Core.Services; +using Bit.Core.Models.Slack; + +namespace Bit.Core.Services; /// Defines operations for interacting with Slack, including OAuth authentication, channel discovery, /// and sending messages. @@ -54,6 +56,6 @@ public interface ISlackService /// A valid Slack OAuth access token. /// The message text to send. /// The channel ID to send the message to. - /// A task that completes when the message has been sent. - Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId); + /// The response from Slack after sending the message. + Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId); } diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs index 2d29494afc..16c756c8c4 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs @@ -6,14 +6,43 @@ public class SlackIntegrationHandler( ISlackService slackService) : IntegrationHandlerBase { + private static readonly HashSet _retryableErrors = new(StringComparer.Ordinal) + { + "internal_error", + "message_limit_exceeded", + "rate_limited", + "ratelimited", + "service_unavailable" + }; + public override async Task HandleAsync(IntegrationMessage message) { - await slackService.SendSlackMessageByChannelIdAsync( + var slackResponse = await slackService.SendSlackMessageByChannelIdAsync( message.Configuration.Token, message.RenderedTemplate, message.Configuration.ChannelId ); - return new IntegrationHandlerResult(success: true, message: message); + if (slackResponse is null) + { + return new IntegrationHandlerResult(success: false, message: message) + { + FailureReason = "Slack response was null" + }; + } + + if (slackResponse.Ok) + { + return new IntegrationHandlerResult(success: true, message: message); + } + + var result = new IntegrationHandlerResult(success: false, message: message) { FailureReason = slackResponse.Error }; + + if (_retryableErrors.Contains(slackResponse.Error)) + { + result.Retryable = true; + } + + return result; } } diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs index 8b691dd4bf..7eec2ec374 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs @@ -1,5 +1,6 @@ using System.Net.Http.Headers; using System.Net.Http.Json; +using System.Text.Json; using System.Web; using Bit.Core.Models.Slack; using Bit.Core.Settings; @@ -71,7 +72,7 @@ public class SlackService( public async Task GetDmChannelByEmailAsync(string token, string email) { var userId = await GetUserIdByEmailAsync(token, email); - return await OpenDmChannel(token, userId); + return await OpenDmChannelAsync(token, userId); } public string GetRedirectUrl(string callbackUrl, string state) @@ -97,21 +98,21 @@ public class SlackService( } var tokenResponse = await _httpClient.PostAsync($"{_slackApiBaseUrl}/oauth.v2.access", - new FormUrlEncodedContent(new[] - { + new FormUrlEncodedContent([ new KeyValuePair("client_id", _clientId), new KeyValuePair("client_secret", _clientSecret), new KeyValuePair("code", code), new KeyValuePair("redirect_uri", redirectUrl) - })); + ])); SlackOAuthResponse? result; try { result = await tokenResponse.Content.ReadFromJsonAsync(); } - catch + catch (JsonException ex) { + logger.LogError(ex, "Error parsing SlackOAuthResponse: invalid JSON"); result = null; } @@ -129,14 +130,25 @@ public class SlackService( return result.AccessToken; } - public async Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId) + public async Task SendSlackMessageByChannelIdAsync(string token, string message, + string channelId) { var payload = JsonContent.Create(new { channel = channelId, text = message }); var request = new HttpRequestMessage(HttpMethod.Post, $"{_slackApiBaseUrl}/chat.postMessage"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); request.Content = payload; - await _httpClient.SendAsync(request); + var response = await _httpClient.SendAsync(request); + + try + { + return await response.Content.ReadFromJsonAsync(); + } + catch (JsonException ex) + { + logger.LogError(ex, "Error parsing Slack message response: invalid JSON"); + return null; + } } private async Task GetUserIdByEmailAsync(string token, string email) @@ -144,7 +156,16 @@ public class SlackService( var request = new HttpRequestMessage(HttpMethod.Get, $"{_slackApiBaseUrl}/users.lookupByEmail?email={email}"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); var response = await _httpClient.SendAsync(request); - var result = await response.Content.ReadFromJsonAsync(); + SlackUserResponse? result; + try + { + result = await response.Content.ReadFromJsonAsync(); + } + catch (JsonException ex) + { + logger.LogError(ex, "Error parsing SlackUserResponse: invalid JSON"); + result = null; + } if (result is null) { @@ -160,7 +181,7 @@ public class SlackService( return result.User.Id; } - private async Task OpenDmChannel(string token, string userId) + private async Task OpenDmChannelAsync(string token, string userId) { if (string.IsNullOrEmpty(userId)) return string.Empty; @@ -170,7 +191,16 @@ public class SlackService( request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); request.Content = payload; var response = await _httpClient.SendAsync(request); - var result = await response.Content.ReadFromJsonAsync(); + SlackDmResponse? result; + try + { + result = await response.Content.ReadFromJsonAsync(); + } + catch (JsonException ex) + { + logger.LogError(ex, "Error parsing SlackDmResponse: invalid JSON"); + result = null; + } if (result is null) { diff --git a/src/Core/AdminConsole/Services/NoopImplementations/NoopSlackService.cs b/src/Core/AdminConsole/Services/NoopImplementations/NoopSlackService.cs index d6c8d08c4c..a54df94814 100644 --- a/src/Core/AdminConsole/Services/NoopImplementations/NoopSlackService.cs +++ b/src/Core/AdminConsole/Services/NoopImplementations/NoopSlackService.cs @@ -1,4 +1,5 @@ -using Bit.Core.Services; +using Bit.Core.Models.Slack; +using Bit.Core.Services; namespace Bit.Core.AdminConsole.Services.NoopImplementations; @@ -24,9 +25,10 @@ public class NoopSlackService : ISlackService return string.Empty; } - public Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId) + public Task SendSlackMessageByChannelIdAsync(string token, string message, + string channelId) { - return Task.FromResult(0); + return Task.FromResult(null); } public Task ObtainTokenViaOAuth(string code, string redirectUrl) diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationControllerTests.cs index 1dd0e86f39..335859e0c4 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationControllerTests.cs @@ -133,6 +133,29 @@ public class OrganizationIntegrationControllerTests .DeleteAsync(organizationIntegration); } + [Theory, BitAutoData] + public async Task PostDeleteAsync_AllParamsProvided_Succeeds( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration organizationIntegration) + { + organizationIntegration.OrganizationId = organizationId; + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(organizationIntegration); + + await sutProvider.Sut.PostDeleteAsync(organizationId, organizationIntegration.Id); + + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(organizationIntegration.Id); + await sutProvider.GetDependency().Received(1) + .DeleteAsync(organizationIntegration); + } + [Theory, BitAutoData] public async Task DeleteAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound( SutProvider sutProvider, diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationsConfigurationControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationsConfigurationControllerTests.cs index 4ccfa70308..9ab626d3f0 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationsConfigurationControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationsConfigurationControllerTests.cs @@ -51,6 +51,36 @@ public class OrganizationIntegrationsConfigurationControllerTests .DeleteAsync(organizationIntegrationConfiguration); } + [Theory, BitAutoData] + public async Task PostDeleteAsync_AllParamsProvided_Succeeds( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration organizationIntegration, + OrganizationIntegrationConfiguration organizationIntegrationConfiguration) + { + organizationIntegration.OrganizationId = organizationId; + organizationIntegrationConfiguration.OrganizationIntegrationId = organizationIntegration.Id; + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(organizationIntegration); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(organizationIntegrationConfiguration); + + await sutProvider.Sut.PostDeleteAsync(organizationId, organizationIntegration.Id, organizationIntegrationConfiguration.Id); + + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(organizationIntegration.Id); + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(organizationIntegrationConfiguration.Id); + await sutProvider.GetDependency().Received(1) + .DeleteAsync(organizationIntegrationConfiguration); + } + [Theory, BitAutoData] public async Task DeleteAsync_IntegrationConfigurationDoesNotExist_ThrowsNotFound( SutProvider sutProvider, @@ -199,27 +229,6 @@ public class OrganizationIntegrationsConfigurationControllerTests .GetManyByIntegrationAsync(organizationIntegration.Id); } - // [Theory, BitAutoData] - // public async Task GetAsync_IntegrationConfigurationDoesNotExist_ThrowsNotFound( - // SutProvider sutProvider, - // Guid organizationId, - // OrganizationIntegration organizationIntegration) - // { - // organizationIntegration.OrganizationId = organizationId; - // sutProvider.Sut.Url = Substitute.For(); - // sutProvider.GetDependency() - // .OrganizationOwner(organizationId) - // .Returns(true); - // sutProvider.GetDependency() - // .GetByIdAsync(Arg.Any()) - // .Returns(organizationIntegration); - // sutProvider.GetDependency() - // .GetByIdAsync(Arg.Any()) - // .ReturnsNull(); - // - // await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetAsync(organizationId, Guid.Empty, Guid.Empty)); - // } - // [Theory, BitAutoData] public async Task GetAsync_IntegrationDoesNotExist_ThrowsNotFound( SutProvider sutProvider, @@ -293,15 +302,16 @@ public class OrganizationIntegrationsConfigurationControllerTests sutProvider.GetDependency() .CreateAsync(Arg.Any()) .Returns(organizationIntegrationConfiguration); - var requestAction = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model); + var createResponse = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model); await sutProvider.GetDependency().Received(1) .CreateAsync(Arg.Any()); - Assert.IsType(requestAction); - Assert.Equal(expected.Id, requestAction.Id); - Assert.Equal(expected.Configuration, requestAction.Configuration); - Assert.Equal(expected.EventType, requestAction.EventType); - Assert.Equal(expected.Template, requestAction.Template); + Assert.IsType(createResponse); + Assert.Equal(expected.Id, createResponse.Id); + Assert.Equal(expected.Configuration, createResponse.Configuration); + Assert.Equal(expected.EventType, createResponse.EventType); + Assert.Equal(expected.Filters, createResponse.Filters); + Assert.Equal(expected.Template, createResponse.Template); } [Theory, BitAutoData] @@ -331,15 +341,16 @@ public class OrganizationIntegrationsConfigurationControllerTests sutProvider.GetDependency() .CreateAsync(Arg.Any()) .Returns(organizationIntegrationConfiguration); - var requestAction = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model); + var createResponse = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model); await sutProvider.GetDependency().Received(1) .CreateAsync(Arg.Any()); - Assert.IsType(requestAction); - Assert.Equal(expected.Id, requestAction.Id); - Assert.Equal(expected.Configuration, requestAction.Configuration); - Assert.Equal(expected.EventType, requestAction.EventType); - Assert.Equal(expected.Template, requestAction.Template); + Assert.IsType(createResponse); + Assert.Equal(expected.Id, createResponse.Id); + Assert.Equal(expected.Configuration, createResponse.Configuration); + Assert.Equal(expected.EventType, createResponse.EventType); + Assert.Equal(expected.Filters, createResponse.Filters); + Assert.Equal(expected.Template, createResponse.Template); } [Theory, BitAutoData] @@ -369,15 +380,16 @@ public class OrganizationIntegrationsConfigurationControllerTests sutProvider.GetDependency() .CreateAsync(Arg.Any()) .Returns(organizationIntegrationConfiguration); - var requestAction = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model); + var createResponse = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model); await sutProvider.GetDependency().Received(1) .CreateAsync(Arg.Any()); - Assert.IsType(requestAction); - Assert.Equal(expected.Id, requestAction.Id); - Assert.Equal(expected.Configuration, requestAction.Configuration); - Assert.Equal(expected.EventType, requestAction.EventType); - Assert.Equal(expected.Template, requestAction.Template); + Assert.IsType(createResponse); + Assert.Equal(expected.Id, createResponse.Id); + Assert.Equal(expected.Configuration, createResponse.Configuration); + Assert.Equal(expected.EventType, createResponse.EventType); + Assert.Equal(expected.Filters, createResponse.Filters); + Assert.Equal(expected.Template, createResponse.Template); } [Theory, BitAutoData] @@ -575,7 +587,7 @@ public class OrganizationIntegrationsConfigurationControllerTests sutProvider.GetDependency() .GetByIdAsync(Arg.Any()) .Returns(organizationIntegrationConfiguration); - var requestAction = await sutProvider.Sut.UpdateAsync( + var updateResponse = await sutProvider.Sut.UpdateAsync( organizationId, organizationIntegration.Id, organizationIntegrationConfiguration.Id, @@ -583,11 +595,12 @@ public class OrganizationIntegrationsConfigurationControllerTests await sutProvider.GetDependency().Received(1) .ReplaceAsync(Arg.Any()); - Assert.IsType(requestAction); - Assert.Equal(expected.Id, requestAction.Id); - Assert.Equal(expected.Configuration, requestAction.Configuration); - Assert.Equal(expected.EventType, requestAction.EventType); - Assert.Equal(expected.Template, requestAction.Template); + Assert.IsType(updateResponse); + Assert.Equal(expected.Id, updateResponse.Id); + Assert.Equal(expected.Configuration, updateResponse.Configuration); + Assert.Equal(expected.EventType, updateResponse.EventType); + Assert.Equal(expected.Filters, updateResponse.Filters); + Assert.Equal(expected.Template, updateResponse.Template); } @@ -619,7 +632,7 @@ public class OrganizationIntegrationsConfigurationControllerTests sutProvider.GetDependency() .GetByIdAsync(Arg.Any()) .Returns(organizationIntegrationConfiguration); - var requestAction = await sutProvider.Sut.UpdateAsync( + var updateResponse = await sutProvider.Sut.UpdateAsync( organizationId, organizationIntegration.Id, organizationIntegrationConfiguration.Id, @@ -627,11 +640,12 @@ public class OrganizationIntegrationsConfigurationControllerTests await sutProvider.GetDependency().Received(1) .ReplaceAsync(Arg.Any()); - Assert.IsType(requestAction); - Assert.Equal(expected.Id, requestAction.Id); - Assert.Equal(expected.Configuration, requestAction.Configuration); - Assert.Equal(expected.EventType, requestAction.EventType); - Assert.Equal(expected.Template, requestAction.Template); + Assert.IsType(updateResponse); + Assert.Equal(expected.Id, updateResponse.Id); + Assert.Equal(expected.Configuration, updateResponse.Configuration); + Assert.Equal(expected.EventType, updateResponse.EventType); + Assert.Equal(expected.Filters, updateResponse.Filters); + Assert.Equal(expected.Template, updateResponse.Template); } [Theory, BitAutoData] @@ -662,7 +676,7 @@ public class OrganizationIntegrationsConfigurationControllerTests sutProvider.GetDependency() .GetByIdAsync(Arg.Any()) .Returns(organizationIntegrationConfiguration); - var requestAction = await sutProvider.Sut.UpdateAsync( + var updateResponse = await sutProvider.Sut.UpdateAsync( organizationId, organizationIntegration.Id, organizationIntegrationConfiguration.Id, @@ -670,11 +684,12 @@ public class OrganizationIntegrationsConfigurationControllerTests await sutProvider.GetDependency().Received(1) .ReplaceAsync(Arg.Any()); - Assert.IsType(requestAction); - Assert.Equal(expected.Id, requestAction.Id); - Assert.Equal(expected.Configuration, requestAction.Configuration); - Assert.Equal(expected.EventType, requestAction.EventType); - Assert.Equal(expected.Template, requestAction.Template); + Assert.IsType(updateResponse); + Assert.Equal(expected.Id, updateResponse.Id); + Assert.Equal(expected.Configuration, updateResponse.Configuration); + Assert.Equal(expected.EventType, updateResponse.EventType); + Assert.Equal(expected.Filters, updateResponse.Filters); + Assert.Equal(expected.Template, updateResponse.Template); } [Theory, BitAutoData] diff --git a/test/Api.Test/AdminConsole/Controllers/SlackIntegrationControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/SlackIntegrationControllerTests.cs index 61d3486c51..c079445559 100644 --- a/test/Api.Test/AdminConsole/Controllers/SlackIntegrationControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/SlackIntegrationControllerTests.cs @@ -71,6 +71,26 @@ public class SlackIntegrationControllerTests await sutProvider.Sut.CreateAsync(string.Empty, state.ToString())); } + [Theory, BitAutoData] + public async Task CreateAsync_CallbackUrlIsEmpty_ThrowsBadRequest( + SutProvider sutProvider, + OrganizationIntegration integration) + { + integration.Type = IntegrationType.Slack; + integration.Configuration = null; + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == "SlackIntegration_Create")) + .Returns((string?)null); + sutProvider.GetDependency() + .GetByIdAsync(integration.Id) + .Returns(integration); + var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.CreateAsync(_validSlackCode, state.ToString())); + } + [Theory, BitAutoData] public async Task CreateAsync_SlackServiceReturnsEmpty_ThrowsBadRequest( SutProvider sutProvider, @@ -153,6 +173,8 @@ public class SlackIntegrationControllerTests OrganizationIntegration wrongOrgIntegration) { wrongOrgIntegration.Id = integration.Id; + wrongOrgIntegration.Type = IntegrationType.Slack; + wrongOrgIntegration.Configuration = null; sutProvider.Sut.Url = Substitute.For(); sutProvider.Sut.Url @@ -304,6 +326,22 @@ public class SlackIntegrationControllerTests await Assert.ThrowsAsync(async () => await sutProvider.Sut.RedirectAsync(organizationId)); } + [Theory, BitAutoData] + public async Task RedirectAsync_CallbackUrlReturnsEmpty_ThrowsBadRequest( + SutProvider sutProvider, + Guid organizationId) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == "SlackIntegration_Create")) + .Returns((string?)null); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.RedirectAsync(organizationId)); + } + [Theory, BitAutoData] public async Task RedirectAsync_SlackServiceReturnsEmpty_ThrowsNotFound( SutProvider sutProvider, diff --git a/test/Api.Test/AdminConsole/Controllers/TeamsIntegrationControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/TeamsIntegrationControllerTests.cs index 3af2affdd8..3302a87372 100644 --- a/test/Api.Test/AdminConsole/Controllers/TeamsIntegrationControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/TeamsIntegrationControllerTests.cs @@ -60,6 +60,26 @@ public class TeamsIntegrationControllerTests Assert.IsType(requestAction); } + [Theory, BitAutoData] + public async Task CreateAsync_CallbackUrlIsEmpty_ThrowsBadRequest( + SutProvider sutProvider, + OrganizationIntegration integration) + { + integration.Type = IntegrationType.Teams; + integration.Configuration = null; + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == "TeamsIntegration_Create")) + .Returns((string?)null); + sutProvider.GetDependency() + .GetByIdAsync(integration.Id) + .Returns(integration); + var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString())); + } + [Theory, BitAutoData] public async Task CreateAsync_CodeIsEmpty_ThrowsBadRequest( SutProvider sutProvider, @@ -315,6 +335,30 @@ public class TeamsIntegrationControllerTests sutProvider.GetDependency().Received(1).GetRedirectUrl(Arg.Any(), expectedState.ToString()); } + [Theory, BitAutoData] + public async Task RedirectAsync_CallbackUrlIsEmpty_ThrowsBadRequest( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration integration) + { + integration.OrganizationId = organizationId; + integration.Configuration = null; + integration.Type = IntegrationType.Teams; + + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == "TeamsIntegration_Create")) + .Returns((string?)null); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetManyByOrganizationAsync(organizationId) + .Returns([integration]); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.RedirectAsync(organizationId)); + } + [Theory, BitAutoData] public async Task RedirectAsync_IntegrationAlreadyExistsWithConfig_ThrowsBadRequest( SutProvider sutProvider, diff --git a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs index 1303e5fe89..76e206abf4 100644 --- a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs +++ b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs @@ -1,14 +1,47 @@ using System.ComponentModel.DataAnnotations; using System.Text.Json; using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.Enums; +using Bit.Test.Common.AutoFixture.Attributes; using Xunit; namespace Bit.Api.Test.AdminConsole.Models.Request.Organizations; public class OrganizationIntegrationRequestModelTests { + [Fact] + public void ToOrganizationIntegration_CreatesNewOrganizationIntegration() + { + var model = new OrganizationIntegrationRequestModel + { + Type = IntegrationType.Hec, + Configuration = JsonSerializer.Serialize(new HecIntegration(Uri: new Uri("http://localhost"), Scheme: "Bearer", Token: "Token")) + }; + + var organizationId = Guid.NewGuid(); + var organizationIntegration = model.ToOrganizationIntegration(organizationId); + + Assert.Equal(organizationIntegration.Type, model.Type); + Assert.Equal(organizationIntegration.Configuration, model.Configuration); + Assert.Equal(organizationIntegration.OrganizationId, organizationId); + } + + [Theory, BitAutoData] + public void ToOrganizationIntegration_UpdatesExistingOrganizationIntegration(OrganizationIntegration integration) + { + var model = new OrganizationIntegrationRequestModel + { + Type = IntegrationType.Hec, + Configuration = JsonSerializer.Serialize(new HecIntegration(Uri: new Uri("http://localhost"), Scheme: "Bearer", Token: "Token")) + }; + + var organizationIntegration = model.ToOrganizationIntegration(integration); + + Assert.Equal(organizationIntegration.Configuration, model.Configuration); + } + [Fact] public void Validate_CloudBillingSync_ReturnsNotYetSupportedError() { diff --git a/test/Core.Test/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContextTests.cs b/test/Core.Test/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContextTests.cs index 930b04121c..cdb109e285 100644 --- a/test/Core.Test/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContextTests.cs +++ b/test/Core.Test/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContextTests.cs @@ -20,6 +20,20 @@ public class IntegrationTemplateContextTests Assert.Equal(expected, sut.EventMessage); } + [Theory, BitAutoData] + public void DateIso8601_ReturnsIso8601FormattedDate(EventMessage eventMessage) + { + var testDate = new DateTime(2025, 10, 27, 13, 30, 0, DateTimeKind.Utc); + eventMessage.Date = testDate; + var sut = new IntegrationTemplateContext(eventMessage); + + var result = sut.DateIso8601; + + Assert.Equal("2025-10-27T13:30:00.0000000Z", result); + // Verify it's valid ISO 8601 + Assert.True(DateTime.TryParse(result, out _)); + } + [Theory, BitAutoData] public void UserName_WhenUserIsSet_ReturnsName(EventMessage eventMessage, User user) { diff --git a/test/Core.Test/AdminConsole/Services/EventIntegrationEventWriteServiceTests.cs b/test/Core.Test/AdminConsole/Services/EventIntegrationEventWriteServiceTests.cs index 03f9c7764d..16df234004 100644 --- a/test/Core.Test/AdminConsole/Services/EventIntegrationEventWriteServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/EventIntegrationEventWriteServiceTests.cs @@ -38,6 +38,20 @@ public class EventIntegrationEventWriteServiceTests organizationId: Arg.Is(orgId => eventMessage.OrganizationId.ToString().Equals(orgId))); } + [Fact] + public async Task CreateManyAsync_EmptyList_DoesNothing() + { + await Subject.CreateManyAsync([]); + await _eventIntegrationPublisher.DidNotReceiveWithAnyArgs().PublishEventAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task DisposeAsync_DisposesEventIntegrationPublisher() + { + await Subject.DisposeAsync(); + await _eventIntegrationPublisher.Received(1).DisposeAsync(); + } + private static bool AssertJsonStringsMatch(EventMessage expected, string body) { var actual = JsonSerializer.Deserialize(body); diff --git a/test/Core.Test/AdminConsole/Services/EventIntegrationHandlerTests.cs b/test/Core.Test/AdminConsole/Services/EventIntegrationHandlerTests.cs index 89207a9d3a..1d94d58aa5 100644 --- a/test/Core.Test/AdminConsole/Services/EventIntegrationHandlerTests.cs +++ b/test/Core.Test/AdminConsole/Services/EventIntegrationHandlerTests.cs @@ -120,6 +120,16 @@ public class EventIntegrationHandlerTests Assert.Empty(_eventIntegrationPublisher.ReceivedCalls()); } + [Theory, BitAutoData] + public async Task HandleEventAsync_NoOrganizationId_DoesNothing(EventMessage eventMessage) + { + var sutProvider = GetSutProvider(OneConfiguration(_templateBase)); + eventMessage.OrganizationId = null; + + await sutProvider.Sut.HandleEventAsync(eventMessage); + Assert.Empty(_eventIntegrationPublisher.ReceivedCalls()); + } + [Theory, BitAutoData] public async Task HandleEventAsync_BaseTemplateOneConfiguration_PublishesIntegrationMessage(EventMessage eventMessage) { diff --git a/test/Core.Test/AdminConsole/Services/IntegrationFilterServiceTests.cs b/test/Core.Test/AdminConsole/Services/IntegrationFilterServiceTests.cs index 4143469a4b..fb33737c16 100644 --- a/test/Core.Test/AdminConsole/Services/IntegrationFilterServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/IntegrationFilterServiceTests.cs @@ -42,6 +42,35 @@ public class IntegrationFilterServiceTests Assert.True(_service.EvaluateFilterGroup(roundtrippedGroup, eventMessage)); } + [Theory, BitAutoData] + public void EvaluateFilterGroup_EqualsUserIdString_Matches(EventMessage eventMessage) + { + var userId = Guid.NewGuid(); + eventMessage.UserId = userId; + + var group = new IntegrationFilterGroup + { + AndOperator = true, + Rules = + [ + new() + { + Property = "UserId", + Operation = IntegrationFilterOperation.Equals, + Value = userId.ToString() + } + ] + }; + + var result = _service.EvaluateFilterGroup(group, eventMessage); + Assert.True(result); + + var jsonGroup = JsonSerializer.Serialize(group); + var roundtrippedGroup = JsonSerializer.Deserialize(jsonGroup); + Assert.NotNull(roundtrippedGroup); + Assert.True(_service.EvaluateFilterGroup(roundtrippedGroup, eventMessage)); + } + [Theory, BitAutoData] public void EvaluateFilterGroup_EqualsUserId_DoesNotMatch(EventMessage eventMessage) { @@ -281,6 +310,45 @@ public class IntegrationFilterServiceTests Assert.True(_service.EvaluateFilterGroup(roundtrippedGroup, eventMessage)); } + + [Theory, BitAutoData] + public void EvaluateFilterGroup_NestedGroups_AnyMatch(EventMessage eventMessage) + { + var id = Guid.NewGuid(); + var collectionId = Guid.NewGuid(); + eventMessage.UserId = id; + eventMessage.CollectionId = collectionId; + + var nestedGroup = new IntegrationFilterGroup + { + AndOperator = false, + Rules = + [ + new() { Property = "UserId", Operation = IntegrationFilterOperation.Equals, Value = id }, + new() + { + Property = "CollectionId", + Operation = IntegrationFilterOperation.In, + Value = new Guid?[] { Guid.NewGuid() } + } + ] + }; + + var topGroup = new IntegrationFilterGroup + { + AndOperator = false, + Groups = [nestedGroup] + }; + + var result = _service.EvaluateFilterGroup(topGroup, eventMessage); + Assert.True(result); + + var jsonGroup = JsonSerializer.Serialize(topGroup); + var roundtrippedGroup = JsonSerializer.Deserialize(jsonGroup); + Assert.NotNull(roundtrippedGroup); + Assert.True(_service.EvaluateFilterGroup(roundtrippedGroup, eventMessage)); + } + [Theory, BitAutoData] public void EvaluateFilterGroup_UnknownProperty_ReturnsFalse(EventMessage eventMessage) { diff --git a/test/Core.Test/AdminConsole/Services/SlackIntegrationHandlerTests.cs b/test/Core.Test/AdminConsole/Services/SlackIntegrationHandlerTests.cs index dab6c41b61..e2e459ceb3 100644 --- a/test/Core.Test/AdminConsole/Services/SlackIntegrationHandlerTests.cs +++ b/test/Core.Test/AdminConsole/Services/SlackIntegrationHandlerTests.cs @@ -1,4 +1,5 @@ using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.Models.Slack; using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -28,6 +29,9 @@ public class SlackIntegrationHandlerTests var sutProvider = GetSutProvider(); message.Configuration = new SlackIntegrationConfigurationDetails(_channelId, _token); + _slackService.SendSlackMessageByChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new SlackSendMessageResponse() { Ok = true, Channel = _channelId }); + var result = await sutProvider.Sut.HandleAsync(message); Assert.True(result.Success); @@ -39,4 +43,97 @@ public class SlackIntegrationHandlerTests Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)) ); } + + [Theory] + [InlineData("service_unavailable")] + [InlineData("ratelimited")] + [InlineData("rate_limited")] + [InlineData("internal_error")] + [InlineData("message_limit_exceeded")] + public async Task HandleAsync_FailedRetryableRequest_ReturnsFailureWithRetryable(string error) + { + var sutProvider = GetSutProvider(); + var message = new IntegrationMessage() + { + Configuration = new SlackIntegrationConfigurationDetails(_channelId, _token), + MessageId = "MessageId", + RenderedTemplate = "Test Message" + }; + + _slackService.SendSlackMessageByChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new SlackSendMessageResponse() { Ok = false, Channel = _channelId, Error = error }); + + var result = await sutProvider.Sut.HandleAsync(message); + + Assert.False(result.Success); + Assert.True(result.Retryable); + Assert.NotNull(result.FailureReason); + Assert.Equal(result.Message, message); + + await sutProvider.GetDependency().Received(1).SendSlackMessageByChannelIdAsync( + Arg.Is(AssertHelper.AssertPropertyEqual(_token)), + Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate)), + Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)) + ); + } + + [Theory] + [InlineData("access_denied")] + [InlineData("channel_not_found")] + [InlineData("token_expired")] + [InlineData("token_revoked")] + public async Task HandleAsync_FailedNonRetryableRequest_ReturnsNonRetryableFailure(string error) + { + var sutProvider = GetSutProvider(); + var message = new IntegrationMessage() + { + Configuration = new SlackIntegrationConfigurationDetails(_channelId, _token), + MessageId = "MessageId", + RenderedTemplate = "Test Message" + }; + + _slackService.SendSlackMessageByChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new SlackSendMessageResponse() { Ok = false, Channel = _channelId, Error = error }); + + var result = await sutProvider.Sut.HandleAsync(message); + + Assert.False(result.Success); + Assert.False(result.Retryable); + Assert.NotNull(result.FailureReason); + Assert.Equal(result.Message, message); + + await sutProvider.GetDependency().Received(1).SendSlackMessageByChannelIdAsync( + Arg.Is(AssertHelper.AssertPropertyEqual(_token)), + Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate)), + Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)) + ); + } + + [Fact] + public async Task HandleAsync_NullResponse_ReturnsNonRetryableFailure() + { + var sutProvider = GetSutProvider(); + var message = new IntegrationMessage() + { + Configuration = new SlackIntegrationConfigurationDetails(_channelId, _token), + MessageId = "MessageId", + RenderedTemplate = "Test Message" + }; + + _slackService.SendSlackMessageByChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((SlackSendMessageResponse?)null); + + var result = await sutProvider.Sut.HandleAsync(message); + + Assert.False(result.Success); + Assert.False(result.Retryable); + Assert.Equal("Slack response was null", result.FailureReason); + Assert.Equal(result.Message, message); + + await sutProvider.GetDependency().Received(1).SendSlackMessageByChannelIdAsync( + Arg.Is(AssertHelper.AssertPropertyEqual(_token)), + Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate)), + Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)) + ); + } } diff --git a/test/Core.Test/AdminConsole/Services/SlackServiceTests.cs b/test/Core.Test/AdminConsole/Services/SlackServiceTests.cs index 48dd9c490e..068e5e8c82 100644 --- a/test/Core.Test/AdminConsole/Services/SlackServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/SlackServiceTests.cs @@ -146,6 +146,27 @@ public class SlackServiceTests Assert.Empty(result); } + [Fact] + public async Task GetChannelIdAsync_NoChannelFound_ReturnsEmptyResult() + { + var emptyResponse = JsonSerializer.Serialize( + new + { + ok = true, + channels = Array.Empty(), + response_metadata = new { next_cursor = "" } + }); + + _handler.When(HttpMethod.Get) + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(emptyResponse)); + + var sutProvider = GetSutProvider(); + var result = await sutProvider.Sut.GetChannelIdAsync(_token, "general"); + + Assert.Empty(result); + } + [Fact] public async Task GetChannelIdAsync_ReturnsCorrectChannelId() { @@ -235,6 +256,32 @@ public class SlackServiceTests Assert.Equal(string.Empty, result); } + [Fact] + public async Task GetDmChannelByEmailAsync_ApiErrorUnparsableDmResponse_ReturnsEmptyString() + { + var sutProvider = GetSutProvider(); + var email = "user@example.com"; + var userId = "U12345"; + + var userResponse = new + { + ok = true, + user = new { id = userId } + }; + + _handler.When($"https://slack.com/api/users.lookupByEmail?email={email}") + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(JsonSerializer.Serialize(userResponse))); + + _handler.When("https://slack.com/api/conversations.open") + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent("NOT JSON")); + + var result = await sutProvider.Sut.GetDmChannelByEmailAsync(_token, email); + + Assert.Equal(string.Empty, result); + } + [Fact] public async Task GetDmChannelByEmailAsync_ApiErrorUserResponse_ReturnsEmptyString() { @@ -244,7 +291,7 @@ public class SlackServiceTests var userResponse = new { ok = false, - error = "An error occured" + error = "An error occurred" }; _handler.When($"https://slack.com/api/users.lookupByEmail?email={email}") @@ -256,6 +303,21 @@ public class SlackServiceTests Assert.Equal(string.Empty, result); } + [Fact] + public async Task GetDmChannelByEmailAsync_ApiErrorUnparsableUserResponse_ReturnsEmptyString() + { + var sutProvider = GetSutProvider(); + var email = "user@example.com"; + + _handler.When($"https://slack.com/api/users.lookupByEmail?email={email}") + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent("Not JSON")); + + var result = await sutProvider.Sut.GetDmChannelByEmailAsync(_token, email); + + Assert.Equal(string.Empty, result); + } + [Fact] public void GetRedirectUrl_ReturnsCorrectUrl() { @@ -341,18 +403,29 @@ public class SlackServiceTests } [Fact] - public async Task SendSlackMessageByChannelId_Sends_Correct_Message() + public async Task SendSlackMessageByChannelId_Success_ReturnsSuccessfulResponse() { var sutProvider = GetSutProvider(); var channelId = "C12345"; var message = "Hello, Slack!"; + var jsonResponse = JsonSerializer.Serialize(new + { + ok = true, + channel = channelId, + }); + _handler.When(HttpMethod.Post) .RespondWith(HttpStatusCode.OK) - .WithContent(new StringContent(string.Empty)); + .WithContent(new StringContent(jsonResponse)); - await sutProvider.Sut.SendSlackMessageByChannelIdAsync(_token, message, channelId); + var result = await sutProvider.Sut.SendSlackMessageByChannelIdAsync(_token, message, channelId); + // Response was parsed correctly + Assert.NotNull(result); + Assert.True(result.Ok); + + // Request was sent correctly Assert.Single(_handler.CapturedRequests); var request = _handler.CapturedRequests[0]; Assert.NotNull(request); @@ -365,4 +438,62 @@ public class SlackServiceTests Assert.Equal(message, json.RootElement.GetProperty("text").GetString() ?? string.Empty); Assert.Equal(channelId, json.RootElement.GetProperty("channel").GetString() ?? string.Empty); } + + [Fact] + public async Task SendSlackMessageByChannelId_Failure_ReturnsErrorResponse() + { + var sutProvider = GetSutProvider(); + var channelId = "C12345"; + var message = "Hello, Slack!"; + + var jsonResponse = JsonSerializer.Serialize(new + { + ok = false, + channel = channelId, + error = "error" + }); + + _handler.When(HttpMethod.Post) + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(jsonResponse)); + + var result = await sutProvider.Sut.SendSlackMessageByChannelIdAsync(_token, message, channelId); + + // Response was parsed correctly + Assert.NotNull(result); + Assert.False(result.Ok); + Assert.NotNull(result.Error); + } + + [Fact] + public async Task SendSlackMessageByChannelIdAsync_InvalidJson_ReturnsNull() + { + var sutProvider = GetSutProvider(); + var channelId = "C12345"; + var message = "Hello world!"; + + _handler.When(HttpMethod.Post) + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent("Not JSON")); + + var result = await sutProvider.Sut.SendSlackMessageByChannelIdAsync(_token, message, channelId); + + Assert.Null(result); + } + + [Fact] + public async Task SendSlackMessageByChannelIdAsync_HttpServerError_ReturnsNull() + { + var sutProvider = GetSutProvider(); + var channelId = "C12345"; + var message = "Hello world!"; + + _handler.When(HttpMethod.Post) + .RespondWith(HttpStatusCode.InternalServerError) + .WithContent(new StringContent(string.Empty)); + + var result = await sutProvider.Sut.SendSlackMessageByChannelIdAsync(_token, message, channelId); + + Assert.Null(result); + } } From 4fac63527270a99b614029e137ff1ee7e494067d Mon Sep 17 00:00:00 2001 From: Brant DeBow <125889545+brant-livefront@users.noreply.github.com> Date: Mon, 10 Nov 2025 14:57:04 -0500 Subject: [PATCH 14/77] Remove EventBasedOrganizationIntegrations feature flag (#6538) * Remove EventBasedOrganizationIntegrations feature flag * Remove unnecessary nullable enable * Refactored service collection extensions to follow a more direct path: ASB, RabbitMQ, Azure Queue, Repository, No-op * Use TryAdd instead of Add --- ...ationIntegrationConfigurationController.cs | 3 - .../OrganizationIntegrationController.cs | 5 -- .../Controllers/SlackIntegrationController.cs | 3 - .../Controllers/TeamsIntegrationController.cs | 3 - .../EventIntegrations/EventRouteService.cs | 34 ---------- src/Core/Constants.cs | 1 - .../Utilities/ServiceCollectionExtensions.cs | 57 +++++++--------- .../Services/EventRouteServiceTests.cs | 65 ------------------- 8 files changed, 24 insertions(+), 147 deletions(-) delete mode 100644 src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRouteService.cs delete mode 100644 test/Core.Test/AdminConsole/Services/EventRouteServiceTests.cs diff --git a/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs b/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs index ae0f91d355..0b7fe8dffe 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs @@ -1,16 +1,13 @@ using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations; -using Bit.Core; using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Bit.Api.AdminConsole.Controllers; -[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)] [Route("organizations/{organizationId:guid}/integrations/{integrationId:guid}/configurations")] [Authorize("Application")] public class OrganizationIntegrationConfigurationController( diff --git a/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs b/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs index a12492949d..181811e892 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs @@ -1,18 +1,13 @@ using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations; -using Bit.Core; using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -#nullable enable - namespace Bit.Api.AdminConsole.Controllers; -[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)] [Route("organizations/{organizationId:guid}/integrations")] [Authorize("Application")] public class OrganizationIntegrationController( diff --git a/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs b/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs index 08635878de..7b53f73f81 100644 --- a/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs +++ b/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs @@ -1,6 +1,5 @@ using System.Text.Json; using Bit.Api.AdminConsole.Models.Response.Organizations; -using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.Context; @@ -8,13 +7,11 @@ using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Bit.Api.AdminConsole.Controllers; -[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)] [Route("organizations")] [Authorize("Application")] public class SlackIntegrationController( diff --git a/src/Api/AdminConsole/Controllers/TeamsIntegrationController.cs b/src/Api/AdminConsole/Controllers/TeamsIntegrationController.cs index 8cafb6b2cf..36d107bbcc 100644 --- a/src/Api/AdminConsole/Controllers/TeamsIntegrationController.cs +++ b/src/Api/AdminConsole/Controllers/TeamsIntegrationController.cs @@ -1,6 +1,5 @@ using System.Text.Json; using Bit.Api.AdminConsole.Models.Response.Organizations; -using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.Context; @@ -8,7 +7,6 @@ using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Bot.Builder; @@ -16,7 +14,6 @@ using Microsoft.Bot.Builder.Integration.AspNet.Core; namespace Bit.Api.AdminConsole.Controllers; -[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)] [Route("organizations")] [Authorize("Application")] public class TeamsIntegrationController( diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRouteService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRouteService.cs deleted file mode 100644 index a542e75a7b..0000000000 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRouteService.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Bit.Core.Models.Data; -using Microsoft.Extensions.DependencyInjection; - -namespace Bit.Core.Services; - -public class EventRouteService( - [FromKeyedServices("broadcast")] IEventWriteService broadcastEventWriteService, - [FromKeyedServices("storage")] IEventWriteService storageEventWriteService, - IFeatureService _featureService) : IEventWriteService -{ - public async Task CreateAsync(IEvent e) - { - if (_featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations)) - { - await broadcastEventWriteService.CreateAsync(e); - } - else - { - await storageEventWriteService.CreateAsync(e); - } - } - - public async Task CreateManyAsync(IEnumerable e) - { - if (_featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations)) - { - await broadcastEventWriteService.CreateManyAsync(e); - } - else - { - await storageEventWriteService.CreateManyAsync(e); - } - } -} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index c5b6bbc10d..ad61d52a38 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -137,7 +137,6 @@ public static class FeatureFlagKeys /* Admin Console Team */ public const string PolicyRequirements = "pm-14439-policy-requirements"; public const string ScimInviteUserOptimization = "pm-16811-optimize-invite-user-flow-to-fail-fast"; - public const string EventBasedOrganizationIntegrations = "event-based-organization-integrations"; public const string CreateDefaultLocation = "pm-19467-create-default-location"; public const string AutomaticConfirmUsers = "pm-19934-auto-confirm-organization-users"; public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache"; diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index ef143b042c..78b8a61015 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -524,42 +524,33 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddEventWriteServices(this IServiceCollection services, GlobalSettings globalSettings) { - if (!globalSettings.SelfHosted && CoreHelpers.SettingHasValue(globalSettings.Events.ConnectionString)) + if (IsAzureServiceBusEnabled(globalSettings)) { - services.TryAddKeyedSingleton("storage"); - - if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.ConnectionString) && - CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.EventTopicName)) - { - services.TryAddSingleton(); - services.TryAddKeyedSingleton("broadcast"); - } - else - { - services.TryAddKeyedSingleton("broadcast"); - } - } - else if (globalSettings.SelfHosted) - { - services.TryAddKeyedSingleton("storage"); - - if (IsRabbitMqEnabled(globalSettings)) - { - services.TryAddSingleton(); - services.TryAddKeyedSingleton("broadcast"); - } - else - { - services.TryAddKeyedSingleton("broadcast"); - } - } - else - { - services.TryAddKeyedSingleton("storage"); - services.TryAddKeyedSingleton("broadcast"); + services.TryAddSingleton(); + services.TryAddSingleton(); + return services; } - services.TryAddScoped(); + if (IsRabbitMqEnabled(globalSettings)) + { + services.TryAddSingleton(); + services.TryAddSingleton(); + return services; + } + + if (CoreHelpers.SettingHasValue(globalSettings.Events.ConnectionString)) + { + services.TryAddSingleton(); + return services; + } + + if (globalSettings.SelfHosted) + { + services.TryAddSingleton(); + return services; + } + + services.TryAddSingleton(); return services; } diff --git a/test/Core.Test/AdminConsole/Services/EventRouteServiceTests.cs b/test/Core.Test/AdminConsole/Services/EventRouteServiceTests.cs deleted file mode 100644 index 1a42d846f2..0000000000 --- a/test/Core.Test/AdminConsole/Services/EventRouteServiceTests.cs +++ /dev/null @@ -1,65 +0,0 @@ -using Bit.Core.Models.Data; -using Bit.Core.Services; -using Bit.Test.Common.AutoFixture.Attributes; -using NSubstitute; -using Xunit; - -namespace Bit.Core.Test.Services; - -[SutProviderCustomize] -public class EventRouteServiceTests -{ - private readonly IEventWriteService _broadcastEventWriteService = Substitute.For(); - private readonly IEventWriteService _storageEventWriteService = Substitute.For(); - private readonly IFeatureService _featureService = Substitute.For(); - private readonly EventRouteService Subject; - - public EventRouteServiceTests() - { - Subject = new EventRouteService(_broadcastEventWriteService, _storageEventWriteService, _featureService); - } - - [Theory, BitAutoData] - public async Task CreateAsync_FlagDisabled_EventSentToStorageService(EventMessage eventMessage) - { - _featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations).Returns(false); - - await Subject.CreateAsync(eventMessage); - - await _broadcastEventWriteService.DidNotReceiveWithAnyArgs().CreateAsync(Arg.Any()); - await _storageEventWriteService.Received(1).CreateAsync(eventMessage); - } - - [Theory, BitAutoData] - public async Task CreateAsync_FlagEnabled_EventSentToBroadcastService(EventMessage eventMessage) - { - _featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations).Returns(true); - - await Subject.CreateAsync(eventMessage); - - await _broadcastEventWriteService.Received(1).CreateAsync(eventMessage); - await _storageEventWriteService.DidNotReceiveWithAnyArgs().CreateAsync(Arg.Any()); - } - - [Theory, BitAutoData] - public async Task CreateManyAsync_FlagDisabled_EventsSentToStorageService(IEnumerable eventMessages) - { - _featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations).Returns(false); - - await Subject.CreateManyAsync(eventMessages); - - await _broadcastEventWriteService.DidNotReceiveWithAnyArgs().CreateManyAsync(Arg.Any>()); - await _storageEventWriteService.Received(1).CreateManyAsync(eventMessages); - } - - [Theory, BitAutoData] - public async Task CreateManyAsync_FlagEnabled_EventsSentToBroadcastService(IEnumerable eventMessages) - { - _featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations).Returns(true); - - await Subject.CreateManyAsync(eventMessages); - - await _broadcastEventWriteService.Received(1).CreateManyAsync(eventMessages); - await _storageEventWriteService.DidNotReceiveWithAnyArgs().CreateManyAsync(Arg.Any>()); - } -} From 4de10c830d3e2f8eaf5192aeca8e9fcb10e7ca3d Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Mon, 10 Nov 2025 14:46:52 -0600 Subject: [PATCH 15/77] [PM-26636] Set Key when Confirming (#6550) * When confirming a uesr, we need to set the key. :face_palm: * Adding default value. --- .../OrganizationUserRepository.cs | 3 +- .../OrganizationUserRepository.cs | 5 ++-- .../OrganizationUser_ConfirmById.sql | 6 ++-- .../OrganizationUserRepositoryTests.cs | 3 ++ .../2025-11-06_00_ConfirmOrgUser_AddKey.sql | 30 +++++++++++++++++++ 5 files changed, 42 insertions(+), 5 deletions(-) create mode 100644 util/Migrator/DbScripts/2025-11-06_00_ConfirmOrgUser_AddKey.sql diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs index dc4fc74ff8..ed5708844d 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -681,7 +681,8 @@ public class OrganizationUserRepository : Repository, IO { organizationUser.Id, organizationUser.UserId, - RevisionDate = DateTime.UtcNow.Date + RevisionDate = DateTime.UtcNow.Date, + Key = organizationUser.Key }); return rowCount > 0; diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs index b871ec44bf..e5016a20d4 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -950,8 +950,9 @@ public class OrganizationUserRepository : Repository ou.Id == organizationUser.Id && ou.Status == OrganizationUserStatusType.Accepted) - .ExecuteUpdateAsync(x => - x.SetProperty(y => y.Status, OrganizationUserStatusType.Confirmed)); + .ExecuteUpdateAsync(x => x + .SetProperty(y => y.Status, OrganizationUserStatusType.Confirmed) + .SetProperty(y => y.Key, organizationUser.Key)); if (result <= 0) { diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_ConfirmById.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_ConfirmById.sql index 004f1c93eb..7a1cd78a51 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationUser_ConfirmById.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_ConfirmById.sql @@ -1,7 +1,8 @@ CREATE PROCEDURE [dbo].[OrganizationUser_ConfirmById] @Id UNIQUEIDENTIFIER, @UserId UNIQUEIDENTIFIER, - @RevisionDate DATETIME2(7) + @RevisionDate DATETIME2(7), + @Key NVARCHAR(MAX) = NULL AS BEGIN SET NOCOUNT ON @@ -12,7 +13,8 @@ BEGIN [dbo].[OrganizationUser] SET [Status] = 2, -- Set to Confirmed - [RevisionDate] = @RevisionDate + [RevisionDate] = @RevisionDate, + [Key] = @Key WHERE [Id] = @Id AND [Status] = 1 -- Only update if status is Accepted diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs index 798571df17..157d6a2589 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs @@ -1484,6 +1484,8 @@ public class OrganizationUserRepositoryTests var organization = await organizationRepository.CreateTestOrganizationAsync(); var user = await userRepository.CreateTestUserAsync(); var orgUser = await organizationUserRepository.CreateAcceptedTestOrganizationUserAsync(organization, user); + const string key = "test-key"; + orgUser.Key = key; // Act var result = await organizationUserRepository.ConfirmOrganizationUserAsync(orgUser); @@ -1493,6 +1495,7 @@ public class OrganizationUserRepositoryTests var updatedUser = await organizationUserRepository.GetByIdAsync(orgUser.Id); Assert.NotNull(updatedUser); Assert.Equal(OrganizationUserStatusType.Confirmed, updatedUser.Status); + Assert.Equal(key, updatedUser.Key); // Annul await organizationRepository.DeleteAsync(organization); diff --git a/util/Migrator/DbScripts/2025-11-06_00_ConfirmOrgUser_AddKey.sql b/util/Migrator/DbScripts/2025-11-06_00_ConfirmOrgUser_AddKey.sql new file mode 100644 index 0000000000..6cf879ee45 --- /dev/null +++ b/util/Migrator/DbScripts/2025-11-06_00_ConfirmOrgUser_AddKey.sql @@ -0,0 +1,30 @@ +CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_ConfirmById] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @RevisionDate DATETIME2(7), + @Key NVARCHAR(MAX) = NULL +AS +BEGIN + SET NOCOUNT ON + + DECLARE @RowCount INT; + + UPDATE + [dbo].[OrganizationUser] + SET + [Status] = 2, -- Set to Confirmed + [RevisionDate] = @RevisionDate, + [Key] = @Key + WHERE + [Id] = @Id + AND [Status] = 1 -- Only update if status is Accepted + + SET @RowCount = @@ROWCOUNT; + + IF @RowCount > 0 + BEGIN + EXEC [dbo].[User_BumpAccountRevisionDate] @UserId + END + + SELECT @RowCount; +END From db36c52c62b7b8174d948b4c6ac78cdca66828a3 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Mon, 10 Nov 2025 16:07:14 -0500 Subject: [PATCH 16/77] Milestone 2C Update (#6560) * fix(billing): milestone update * tests(billing): update tests --- .../Services/Implementations/UpcomingInvoiceHandler.cs | 3 ++- src/Core/Billing/Pricing/PricingClient.cs | 4 +--- test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs | 3 ++- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index f24229f151..7a58f84cd4 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -177,7 +177,8 @@ public class UpcomingInvoiceHandler( Discounts = [ new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone2SubscriptionDiscount } - ] + ], + ProrationBehavior = "none" }); } catch (Exception exception) diff --git a/src/Core/Billing/Pricing/PricingClient.cs b/src/Core/Billing/Pricing/PricingClient.cs index 0c4266665a..1ec44c6496 100644 --- a/src/Core/Billing/Pricing/PricingClient.cs +++ b/src/Core/Billing/Pricing/PricingClient.cs @@ -123,9 +123,7 @@ public class PricingClient( return [CurrentPremiumPlan]; } - var milestone2Feature = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2); - - var response = await httpClient.GetAsync($"plans/premium?milestone2={milestone2Feature}"); + var response = await httpClient.GetAsync("plans/premium"); if (response.IsSuccessStatusCode) { diff --git a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs index 899df4ea53..913355f2db 100644 --- a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs +++ b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs @@ -268,7 +268,8 @@ public class UpcomingInvoiceHandlerTests Arg.Is("sub_123"), Arg.Is(o => o.Items[0].Id == priceSubscriptionId && - o.Items[0].Price == priceId)); + o.Items[0].Price == priceId && + o.ProrationBehavior == "none")); // Verify the updated invoice email was sent await _mailer.Received(1).SendEmail( From ea233580d24734f78feffe7fa5cef185b1a2df28 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Tue, 11 Nov 2025 14:29:55 -0600 Subject: [PATCH 17/77] add vault skeleton loader feature flag (#6566) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index ad61d52a38..3a48380e87 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -252,6 +252,7 @@ public static class FeatureFlagKeys public const string PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption"; public const string PM23904_RiskInsightsForPremium = "pm-23904-risk-insights-for-premium"; public const string PM25083_AutofillConfirmFromSearch = "pm-25083-autofill-confirm-from-search"; + public const string VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders"; /* Innovation Team */ public const string ArchiveVaultItems = "pm-19148-innovation-archive"; From 691047039b7b10c7a9e676ff4256f494a9fd0c39 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Tue, 11 Nov 2025 16:01:48 -0600 Subject: [PATCH 18/77] [PM-27849] Check for `sm-standalone` on subscription (#6545) * Fix coupon check * Fixed in FF off scenario * Run dotnet format --- .../Queries/GetOrganizationMetadataQuery.cs | 20 ++- .../Services/OrganizationBillingService.cs | 15 +- .../GetOrganizationMetadataQueryTests.cs | 135 ++++++++---------- .../OrganizationBillingServiceTests.cs | 50 +++---- 4 files changed, 107 insertions(+), 113 deletions(-) diff --git a/src/Core/Billing/Organizations/Queries/GetOrganizationMetadataQuery.cs b/src/Core/Billing/Organizations/Queries/GetOrganizationMetadataQuery.cs index 63da0477a1..493bae2872 100644 --- a/src/Core/Billing/Organizations/Queries/GetOrganizationMetadataQuery.cs +++ b/src/Core/Billing/Organizations/Queries/GetOrganizationMetadataQuery.cs @@ -22,11 +22,6 @@ public class GetOrganizationMetadataQuery( { public async Task Run(Organization organization) { - if (organization == null) - { - return null; - } - if (globalSettings.SelfHosted) { return OrganizationMetadata.Default; @@ -42,10 +37,12 @@ public class GetOrganizationMetadataQuery( }; } - var customer = await subscriberService.GetCustomer(organization, - new CustomerGetOptions { Expand = ["discount.coupon.applies_to"] }); + var customer = await subscriberService.GetCustomer(organization); - var subscription = await subscriberService.GetSubscription(organization); + var subscription = await subscriberService.GetSubscription(organization, new SubscriptionGetOptions + { + Expand = ["discounts.coupon.applies_to"] + }); if (customer == null || subscription == null) { @@ -79,16 +76,17 @@ public class GetOrganizationMetadataQuery( return false; } - var hasCoupon = customer.Discount?.Coupon?.Id == StripeConstants.CouponIDs.SecretsManagerStandalone; + var coupon = subscription.Discounts?.FirstOrDefault(discount => + discount.Coupon?.Id == StripeConstants.CouponIDs.SecretsManagerStandalone)?.Coupon; - if (!hasCoupon) + if (coupon == null) { return false; } var subscriptionProductIds = subscription.Items.Data.Select(item => item.Plan.ProductId); - var couponAppliesTo = customer.Discount?.Coupon?.AppliesTo?.Products; + var couponAppliesTo = coupon.AppliesTo?.Products; return subscriptionProductIds.Intersect(couponAppliesTo ?? []).Any(); } diff --git a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs index 2381bdda96..b10f04d766 100644 --- a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs +++ b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs @@ -79,10 +79,12 @@ public class OrganizationBillingService( }; } - var customer = await subscriberService.GetCustomer(organization, - new CustomerGetOptions { Expand = ["discount.coupon.applies_to"] }); + var customer = await subscriberService.GetCustomer(organization); - var subscription = await subscriberService.GetSubscription(organization); + var subscription = await subscriberService.GetSubscription(organization, new SubscriptionGetOptions + { + Expand = ["discounts.coupon.applies_to"] + }); if (customer == null || subscription == null) { @@ -542,16 +544,17 @@ public class OrganizationBillingService( return false; } - var hasCoupon = customer.Discount?.Coupon?.Id == StripeConstants.CouponIDs.SecretsManagerStandalone; + var coupon = subscription.Discounts?.FirstOrDefault(discount => + discount.Coupon?.Id == StripeConstants.CouponIDs.SecretsManagerStandalone)?.Coupon; - if (!hasCoupon) + if (coupon == null) { return false; } var subscriptionProductIds = subscription.Items.Data.Select(item => item.Plan.ProductId); - var couponAppliesTo = customer.Discount?.Coupon?.AppliesTo?.Products; + var couponAppliesTo = coupon.AppliesTo?.Products; return subscriptionProductIds.Intersect(couponAppliesTo ?? []).Any(); } diff --git a/test/Core.Test/Billing/Organizations/Queries/GetOrganizationMetadataQueryTests.cs b/test/Core.Test/Billing/Organizations/Queries/GetOrganizationMetadataQueryTests.cs index 21081112d7..9f4b8474b5 100644 --- a/test/Core.Test/Billing/Organizations/Queries/GetOrganizationMetadataQueryTests.cs +++ b/test/Core.Test/Billing/Organizations/Queries/GetOrganizationMetadataQueryTests.cs @@ -21,15 +21,6 @@ namespace Bit.Core.Test.Billing.Organizations.Queries; [SutProviderCustomize] public class GetOrganizationMetadataQueryTests { - [Theory, BitAutoData] - public async Task Run_NullOrganization_ReturnsNull( - SutProvider sutProvider) - { - var result = await sutProvider.Sut.Run(null); - - Assert.Null(result); - } - [Theory, BitAutoData] public async Task Run_SelfHosted_ReturnsDefault( Organization organization, @@ -74,8 +65,7 @@ public class GetOrganizationMetadataQueryTests .Returns(new OrganizationSeatCounts { Users = 5, Sponsored = 0 }); sutProvider.GetDependency() - .GetCustomer(organization, Arg.Is(options => - options.Expand.Contains("discount.coupon.applies_to"))) + .GetCustomer(organization) .ReturnsNull(); var result = await sutProvider.Sut.Run(organization); @@ -100,12 +90,12 @@ public class GetOrganizationMetadataQueryTests .Returns(new OrganizationSeatCounts { Users = 7, Sponsored = 0 }); sutProvider.GetDependency() - .GetCustomer(organization, Arg.Is(options => - options.Expand.Contains("discount.coupon.applies_to"))) + .GetCustomer(organization) .Returns(customer); sutProvider.GetDependency() - .GetSubscription(organization) + .GetSubscription(organization, Arg.Is(options => + options.Expand.Contains("discounts.coupon.applies_to"))) .ReturnsNull(); var result = await sutProvider.Sut.Run(organization); @@ -124,23 +114,24 @@ public class GetOrganizationMetadataQueryTests organization.PlanType = PlanType.EnterpriseAnnually; var productId = "product_123"; - var customer = new Customer - { - Discount = new Discount - { - Coupon = new Coupon - { - Id = StripeConstants.CouponIDs.SecretsManagerStandalone, - AppliesTo = new CouponAppliesTo - { - Products = [productId] - } - } - } - }; + var customer = new Customer(); var subscription = new Subscription { + Discounts = + [ + new Discount + { + Coupon = new Coupon + { + Id = StripeConstants.CouponIDs.SecretsManagerStandalone, + AppliesTo = new CouponAppliesTo + { + Products = [productId] + } + } + } + ], Items = new StripeList { Data = @@ -162,12 +153,12 @@ public class GetOrganizationMetadataQueryTests .Returns(new OrganizationSeatCounts { Users = 15, Sponsored = 0 }); sutProvider.GetDependency() - .GetCustomer(organization, Arg.Is(options => - options.Expand.Contains("discount.coupon.applies_to"))) + .GetCustomer(organization) .Returns(customer); sutProvider.GetDependency() - .GetSubscription(organization) + .GetSubscription(organization, Arg.Is(options => + options.Expand.Contains("discounts.coupon.applies_to"))) .Returns(subscription); sutProvider.GetDependency() @@ -189,13 +180,11 @@ public class GetOrganizationMetadataQueryTests organization.GatewaySubscriptionId = "sub_123"; organization.PlanType = PlanType.TeamsAnnually; - var customer = new Customer - { - Discount = null - }; + var customer = new Customer(); var subscription = new Subscription { + Discounts = null, Items = new StripeList { Data = @@ -217,12 +206,12 @@ public class GetOrganizationMetadataQueryTests .Returns(new OrganizationSeatCounts { Users = 20, Sponsored = 0 }); sutProvider.GetDependency() - .GetCustomer(organization, Arg.Is(options => - options.Expand.Contains("discount.coupon.applies_to"))) + .GetCustomer(organization) .Returns(customer); sutProvider.GetDependency() - .GetSubscription(organization) + .GetSubscription(organization, Arg.Is(options => + options.Expand.Contains("discounts.coupon.applies_to"))) .Returns(subscription); sutProvider.GetDependency() @@ -244,23 +233,24 @@ public class GetOrganizationMetadataQueryTests organization.GatewaySubscriptionId = "sub_123"; organization.PlanType = PlanType.EnterpriseAnnually; - var customer = new Customer - { - Discount = new Discount - { - Coupon = new Coupon - { - Id = StripeConstants.CouponIDs.SecretsManagerStandalone, - AppliesTo = new CouponAppliesTo - { - Products = ["different_product_id"] - } - } - } - }; + var customer = new Customer(); var subscription = new Subscription { + Discounts = + [ + new Discount + { + Coupon = new Coupon + { + Id = StripeConstants.CouponIDs.SecretsManagerStandalone, + AppliesTo = new CouponAppliesTo + { + Products = ["different_product_id"] + } + } + } + ], Items = new StripeList { Data = @@ -282,12 +272,12 @@ public class GetOrganizationMetadataQueryTests .Returns(new OrganizationSeatCounts { Users = 12, Sponsored = 0 }); sutProvider.GetDependency() - .GetCustomer(organization, Arg.Is(options => - options.Expand.Contains("discount.coupon.applies_to"))) + .GetCustomer(organization) .Returns(customer); sutProvider.GetDependency() - .GetSubscription(organization) + .GetSubscription(organization, Arg.Is(options => + options.Expand.Contains("discounts.coupon.applies_to"))) .Returns(subscription); sutProvider.GetDependency() @@ -310,23 +300,24 @@ public class GetOrganizationMetadataQueryTests organization.PlanType = PlanType.FamiliesAnnually; var productId = "product_123"; - var customer = new Customer - { - Discount = new Discount - { - Coupon = new Coupon - { - Id = StripeConstants.CouponIDs.SecretsManagerStandalone, - AppliesTo = new CouponAppliesTo - { - Products = [productId] - } - } - } - }; + var customer = new Customer(); var subscription = new Subscription { + Discounts = + [ + new Discount + { + Coupon = new Coupon + { + Id = StripeConstants.CouponIDs.SecretsManagerStandalone, + AppliesTo = new CouponAppliesTo + { + Products = [productId] + } + } + } + ], Items = new StripeList { Data = @@ -348,12 +339,12 @@ public class GetOrganizationMetadataQueryTests .Returns(new OrganizationSeatCounts { Users = 8, Sponsored = 0 }); sutProvider.GetDependency() - .GetCustomer(organization, Arg.Is(options => - options.Expand.Contains("discount.coupon.applies_to"))) + .GetCustomer(organization) .Returns(customer); sutProvider.GetDependency() - .GetSubscription(organization) + .GetSubscription(organization, Arg.Is(options => + options.Expand.Contains("discounts.coupon.applies_to"))) .Returns(subscription); sutProvider.GetDependency() diff --git a/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs b/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs index 224328d71b..40fa4c412d 100644 --- a/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs +++ b/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs @@ -38,31 +38,32 @@ public class OrganizationBillingServiceTests var subscriberService = sutProvider.GetDependency(); var organizationSeatCount = new OrganizationSeatCounts { Users = 1, Sponsored = 0 }; - var customer = new Customer - { - Discount = new Discount - { - Coupon = new Coupon - { - Id = StripeConstants.CouponIDs.SecretsManagerStandalone, - AppliesTo = new CouponAppliesTo - { - Products = ["product_id"] - } - } - } - }; + var customer = new Customer(); subscriberService - .GetCustomer(organization, Arg.Is(options => - options.Expand.Contains("discount.coupon.applies_to"))) + .GetCustomer(organization) .Returns(customer); - subscriberService.GetSubscription(organization).Returns(new Subscription - { - Items = new StripeList + subscriberService.GetSubscription(organization, Arg.Is(options => + options.Expand.Contains("discounts.coupon.applies_to"))).Returns(new Subscription { - Data = + Discounts = + [ + new Discount + { + Coupon = new Coupon + { + Id = StripeConstants.CouponIDs.SecretsManagerStandalone, + AppliesTo = new CouponAppliesTo + { + Products = ["product_id"] + } + } + } + ], + Items = new StripeList + { + Data = [ new SubscriptionItem { @@ -72,8 +73,8 @@ public class OrganizationBillingServiceTests } } ] - } - }); + } + }); sutProvider.GetDependency() .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id) @@ -109,11 +110,12 @@ public class OrganizationBillingServiceTests // Set up subscriber service to return null for customer subscriberService - .GetCustomer(organization, Arg.Is(options => options.Expand.FirstOrDefault() == "discount.coupon.applies_to")) + .GetCustomer(organization) .Returns((Customer)null); // Set up subscriber service to return null for subscription - subscriberService.GetSubscription(organization).Returns((Subscription)null); + subscriberService.GetSubscription(organization, Arg.Is(options => + options.Expand.Contains("discounts.coupon.applies_to"))).Returns((Subscription)null); var metadata = await sutProvider.Sut.GetMetadata(organizationId); From de90108e0f55517adf3e70f6bc3fffaa4d597d7a Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Tue, 11 Nov 2025 18:44:18 -0500 Subject: [PATCH 19/77] [PM-27579] bw sync does not pull in new items stored in a collection (#6562) * Updated sproc to update account revision date after cipher has been created in collection * check response from update collection success * removed the org check --- .../Cipher/Cipher_CreateWithCollections.sql | 6 ++++ ...ccountRevisionDateCipherWithCollection.sql | 32 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 util/Migrator/DbScripts/2025-11-10_00_BumpAccountRevisionDateCipherWithCollection.sql diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_CreateWithCollections.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_CreateWithCollections.sql index ac7be1bbae..c6816a1226 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_CreateWithCollections.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_CreateWithCollections.sql @@ -23,4 +23,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/util/Migrator/DbScripts/2025-11-10_00_BumpAccountRevisionDateCipherWithCollection.sql b/util/Migrator/DbScripts/2025-11-10_00_BumpAccountRevisionDateCipherWithCollection.sql new file mode 100644 index 0000000000..8625c14d22 --- /dev/null +++ b/util/Migrator/DbScripts/2025-11-10_00_BumpAccountRevisionDateCipherWithCollection.sql @@ -0,0 +1,32 @@ +CREATE OR ALTER PROCEDURE [dbo].[Cipher_CreateWithCollections] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Type TINYINT, + @Data NVARCHAR(MAX), + @Favorites NVARCHAR(MAX), + @Folders NVARCHAR(MAX), + @Attachments NVARCHAR(MAX), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @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].[Cipher_Create] @Id, @UserId, @OrganizationId, @Type, @Data, @Favorites, @Folders, + @Attachments, @CreationDate, @RevisionDate, @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 From f0ec201745b77d136cdb17418f6118b56a823e30 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Wed, 12 Nov 2025 20:38:21 +0100 Subject: [PATCH 20/77] [PM 26682]milestone 2d display discount on subscription page (#6542) * The discount badge implementation * Address the claude pr comments * Add more unit testing * Add more test * used existing flag * Add the coupon Ids * Add more code documentation * Add some recommendation from claude * Fix addition comments and prs * Add more integration test * Fix some comment and add more test * rename the test methods * Add more unit test and comments * Resolve the null issues * Add more test * reword the comments * Rename Variable * Some code refactoring * Change the coupon ID to milestone-2c * Fix the failing Test --- .../Billing/Controllers/AccountsController.cs | 30 +- .../Response/SubscriptionResponseModel.cs | 139 ++- src/Core/Billing/Constants/StripeConstants.cs | 2 +- src/Core/Models/Business/SubscriptionInfo.cs | 116 ++- .../Implementations/StripePaymentService.cs | 14 +- .../Controllers/AccountsControllerTests.cs | 800 ++++++++++++++++++ .../SubscriptionResponseModelTests.cs | 400 +++++++++ .../Business/BillingCustomerDiscountTests.cs | 497 +++++++++++ .../Models/Business/SubscriptionInfoTests.cs | 125 +++ .../Services/StripePaymentServiceTests.cs | 396 +++++++++ 10 files changed, 2460 insertions(+), 59 deletions(-) create mode 100644 test/Api.Test/Billing/Controllers/AccountsControllerTests.cs create mode 100644 test/Api.Test/Models/Response/SubscriptionResponseModelTests.cs create mode 100644 test/Core.Test/Models/Business/BillingCustomerDiscountTests.cs create mode 100644 test/Core.Test/Models/Business/SubscriptionInfoTests.cs diff --git a/src/Api/Billing/Controllers/AccountsController.cs b/src/Api/Billing/Controllers/AccountsController.cs index 9dbe4a5532..075218dd74 100644 --- a/src/Api/Billing/Controllers/AccountsController.cs +++ b/src/Api/Billing/Controllers/AccountsController.cs @@ -4,6 +4,7 @@ using Bit.Api.Models.Request; using Bit.Api.Models.Request.Accounts; using Bit.Api.Models.Response; using Bit.Api.Utilities; +using Bit.Core; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Business; @@ -24,7 +25,8 @@ namespace Bit.Api.Billing.Controllers; public class AccountsController( IUserService userService, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, - IUserAccountKeysQuery userAccountKeysQuery) : Controller + IUserAccountKeysQuery userAccountKeysQuery, + IFeatureService featureService) : Controller { [HttpPost("premium")] public async Task PostPremiumAsync( @@ -84,16 +86,24 @@ public class AccountsController( throw new UnauthorizedAccessException(); } - if (!globalSettings.SelfHosted && user.Gateway != null) + // Only cloud-hosted users with payment gateways have subscription and discount information + if (!globalSettings.SelfHosted) { - var subscriptionInfo = await paymentService.GetSubscriptionAsync(user); - var license = await userService.GenerateLicenseAsync(user, subscriptionInfo); - return new SubscriptionResponseModel(user, subscriptionInfo, license); - } - else if (!globalSettings.SelfHosted) - { - var license = await userService.GenerateLicenseAsync(user); - return new SubscriptionResponseModel(user, license); + if (user.Gateway != null) + { + // Note: PM23341_Milestone_2 is the feature flag for the overall Milestone 2 initiative (PM-23341). + // This specific implementation (PM-26682) adds discount display functionality as part of that initiative. + // The feature flag controls the broader Milestone 2 feature set, not just this specific task. + var includeMilestone2Discount = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2); + var subscriptionInfo = await paymentService.GetSubscriptionAsync(user); + var license = await userService.GenerateLicenseAsync(user, subscriptionInfo); + return new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount); + } + else + { + var license = await userService.GenerateLicenseAsync(user); + return new SubscriptionResponseModel(user, license); + } } else { diff --git a/src/Api/Models/Response/SubscriptionResponseModel.cs b/src/Api/Models/Response/SubscriptionResponseModel.cs index 7038bee2a7..29a47e160c 100644 --- a/src/Api/Models/Response/SubscriptionResponseModel.cs +++ b/src/Api/Models/Response/SubscriptionResponseModel.cs @@ -1,6 +1,4 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - +using Bit.Core.Billing.Constants; using Bit.Core.Billing.Models.Business; using Bit.Core.Entities; using Bit.Core.Models.Api; @@ -11,7 +9,17 @@ namespace Bit.Api.Models.Response; public class SubscriptionResponseModel : ResponseModel { - public SubscriptionResponseModel(User user, SubscriptionInfo subscription, UserLicense license) + + /// The user entity containing storage and premium subscription information + /// Subscription information retrieved from the payment provider (Stripe/Braintree) + /// The user's license containing expiration and feature entitlements + /// + /// Whether to include discount information in the response. + /// Set to true when the PM23341_Milestone_2 feature flag is enabled AND + /// you want to expose Milestone 2 discount information to the client. + /// The discount will only be included if it matches the specific Milestone 2 coupon ID. + /// + public SubscriptionResponseModel(User user, SubscriptionInfo subscription, UserLicense license, bool includeMilestone2Discount = false) : base("subscription") { Subscription = subscription.Subscription != null ? new BillingSubscription(subscription.Subscription) : null; @@ -22,9 +30,14 @@ public class SubscriptionResponseModel : ResponseModel MaxStorageGb = user.MaxStorageGb; License = license; Expiration = License.Expires; + + // Only display the Milestone 2 subscription discount on the subscription page. + CustomerDiscount = ShouldIncludeMilestone2Discount(includeMilestone2Discount, subscription.CustomerDiscount) + ? new BillingCustomerDiscount(subscription.CustomerDiscount!) + : null; } - public SubscriptionResponseModel(User user, UserLicense license = null) + public SubscriptionResponseModel(User user, UserLicense? license = null) : base("subscription") { StorageName = user.Storage.HasValue ? CoreHelpers.ReadableBytesSize(user.Storage.Value) : null; @@ -38,21 +51,109 @@ public class SubscriptionResponseModel : ResponseModel } } - public string StorageName { get; set; } + public string? StorageName { get; set; } public double? StorageGb { get; set; } public short? MaxStorageGb { get; set; } - public BillingSubscriptionUpcomingInvoice UpcomingInvoice { get; set; } - public BillingSubscription Subscription { get; set; } - public UserLicense License { get; set; } + public BillingSubscriptionUpcomingInvoice? UpcomingInvoice { get; set; } + public BillingSubscription? Subscription { get; set; } + /// + /// Customer discount information from Stripe for the Milestone 2 subscription discount. + /// Only includes the specific Milestone 2 coupon (cm3nHfO1) when it's a perpetual discount (no expiration). + /// This is for display purposes only and does not affect Stripe's automatic discount application. + /// Other discounts may still apply in Stripe billing but are not included in this response. + /// + /// Null when: + /// - The PM23341_Milestone_2 feature flag is disabled + /// - There is no active discount + /// - The discount coupon ID doesn't match the Milestone 2 coupon (cm3nHfO1) + /// - The instance is self-hosted + /// + /// + public BillingCustomerDiscount? CustomerDiscount { get; set; } + public UserLicense? License { get; set; } public DateTime? Expiration { get; set; } + + /// + /// Determines whether the Milestone 2 discount should be included in the response. + /// + /// Whether the feature flag is enabled and discount should be considered. + /// The customer discount from subscription info, if any. + /// True if the discount should be included; false otherwise. + private static bool ShouldIncludeMilestone2Discount( + bool includeMilestone2Discount, + SubscriptionInfo.BillingCustomerDiscount? customerDiscount) + { + return includeMilestone2Discount && + customerDiscount != null && + customerDiscount.Id == StripeConstants.CouponIDs.Milestone2SubscriptionDiscount && + customerDiscount.Active; + } } -public class BillingCustomerDiscount(SubscriptionInfo.BillingCustomerDiscount discount) +/// +/// Customer discount information from Stripe billing. +/// +public class BillingCustomerDiscount { - public string Id { get; } = discount.Id; - public bool Active { get; } = discount.Active; - public decimal? PercentOff { get; } = discount.PercentOff; - public List AppliesTo { get; } = discount.AppliesTo; + /// + /// The Stripe coupon ID (e.g., "cm3nHfO1"). + /// + public string? Id { get; } + + /// + /// Whether the discount is a recurring/perpetual discount with no expiration date. + /// + /// This property is true only when the discount has no end date, meaning it applies + /// indefinitely to all future renewals. This is a product decision for Milestone 2 + /// to only display perpetual discounts in the UI. + /// + /// + /// Note: This does NOT indicate whether the discount is "currently active" in the billing sense. + /// A discount with a future end date is functionally active and will be applied by Stripe, + /// but this property will be false because it has an expiration date. + /// + /// + public bool Active { get; } + + /// + /// Percentage discount applied to the subscription (e.g., 20.0 for 20% off). + /// Null if this is an amount-based discount. + /// + public decimal? PercentOff { get; } + + /// + /// Fixed amount discount in USD (e.g., 14.00 for $14 off). + /// Converted from Stripe's cent-based values (1400 cents → $14.00). + /// Null if this is a percentage-based discount. + /// Note: Stripe stores amounts in the smallest currency unit. This value is always in USD. + /// + public decimal? AmountOff { get; } + + /// + /// List of Stripe product IDs that this discount applies to (e.g., ["prod_premium", "prod_families"]). + /// + /// Null: discount applies to all products with no restrictions (AppliesTo not specified in Stripe). + /// Empty list: discount restricted to zero products (edge case - AppliesTo.Products = [] in Stripe). + /// Non-empty list: discount applies only to the specified product IDs. + /// + /// + public IReadOnlyList? AppliesTo { get; } + + /// + /// Creates a BillingCustomerDiscount from a SubscriptionInfo.BillingCustomerDiscount. + /// + /// The discount to convert. Must not be null. + /// Thrown when discount is null. + public BillingCustomerDiscount(SubscriptionInfo.BillingCustomerDiscount discount) + { + ArgumentNullException.ThrowIfNull(discount); + + Id = discount.Id; + Active = discount.Active; + PercentOff = discount.PercentOff; + AmountOff = discount.AmountOff; + AppliesTo = discount.AppliesTo; + } } public class BillingSubscription @@ -83,10 +184,10 @@ public class BillingSubscription public DateTime? PeriodEndDate { get; set; } public DateTime? CancelledDate { get; set; } public bool CancelAtEndDate { get; set; } - public string Status { get; set; } + public string? Status { get; set; } public bool Cancelled { get; set; } public IEnumerable Items { get; set; } = new List(); - public string CollectionMethod { get; set; } + public string? CollectionMethod { get; set; } public DateTime? SuspensionDate { get; set; } public DateTime? UnpaidPeriodEndDate { get; set; } public int? GracePeriod { get; set; } @@ -104,11 +205,11 @@ public class BillingSubscription AddonSubscriptionItem = item.AddonSubscriptionItem; } - public string ProductId { get; set; } - public string Name { get; set; } + public string? ProductId { get; set; } + public string? Name { get; set; } public decimal Amount { get; set; } public int Quantity { get; set; } - public string Interval { get; set; } + public string? Interval { get; set; } public bool SponsoredSubscriptionItem { get; set; } public bool AddonSubscriptionItem { get; set; } } diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index 517273db4e..9cfb4e9b0d 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -22,7 +22,7 @@ public static class StripeConstants { public const string LegacyMSPDiscount = "msp-discount-35"; public const string SecretsManagerStandalone = "sm-standalone"; - public const string Milestone2SubscriptionDiscount = "cm3nHfO1"; + public const string Milestone2SubscriptionDiscount = "milestone-2c"; public static class MSPDiscounts { diff --git a/src/Core/Models/Business/SubscriptionInfo.cs b/src/Core/Models/Business/SubscriptionInfo.cs index f8a96a189f..be514cb39f 100644 --- a/src/Core/Models/Business/SubscriptionInfo.cs +++ b/src/Core/Models/Business/SubscriptionInfo.cs @@ -1,58 +1,118 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Extensions; using Stripe; +#nullable enable + namespace Bit.Core.Models.Business; public class SubscriptionInfo { - public BillingCustomerDiscount CustomerDiscount { get; set; } - public BillingSubscription Subscription { get; set; } - public BillingUpcomingInvoice UpcomingInvoice { get; set; } + /// + /// Converts Stripe's minor currency units (cents) to major currency units (dollars). + /// IMPORTANT: Only supports USD. All Bitwarden subscriptions are USD-only. + /// + private const decimal StripeMinorUnitDivisor = 100M; + /// + /// Converts Stripe's minor currency units (cents) to major currency units (dollars). + /// Preserves null semantics to distinguish between "no amount" (null) and "zero amount" (0.00m). + /// + /// The amount in Stripe's minor currency units (e.g., cents for USD). + /// The amount in major currency units (e.g., dollars for USD), or null if the input is null. + private static decimal? ConvertFromStripeMinorUnits(long? amountInCents) + { + return amountInCents.HasValue ? amountInCents.Value / StripeMinorUnitDivisor : null; + } + + public BillingCustomerDiscount? CustomerDiscount { get; set; } + public BillingSubscription? Subscription { get; set; } + public BillingUpcomingInvoice? UpcomingInvoice { get; set; } + + /// + /// Represents customer discount information from Stripe billing. + /// public class BillingCustomerDiscount { public BillingCustomerDiscount() { } + /// + /// Creates a BillingCustomerDiscount from a Stripe Discount object. + /// + /// The Stripe discount containing coupon and expiration information. public BillingCustomerDiscount(Discount discount) { Id = discount.Coupon?.Id; + // Active = true only for perpetual/recurring discounts (no end date) + // This is intentional for Milestone 2 - only perpetual discounts are shown in UI Active = discount.End == null; PercentOff = discount.Coupon?.PercentOff; - AppliesTo = discount.Coupon?.AppliesTo?.Products ?? []; + AmountOff = ConvertFromStripeMinorUnits(discount.Coupon?.AmountOff); + // Stripe's CouponAppliesTo.Products is already IReadOnlyList, so no conversion needed + AppliesTo = discount.Coupon?.AppliesTo?.Products; } - public string Id { get; set; } + /// + /// The Stripe coupon ID (e.g., "cm3nHfO1"). + /// Note: Only specific coupon IDs are displayed in the UI based on feature flag configuration, + /// though Stripe may apply additional discounts that are not shown. + /// + public string? Id { get; set; } + + /// + /// True only for perpetual/recurring discounts (End == null). + /// False for any discount with an expiration date, even if not yet expired. + /// Product decision for Milestone 2: only show perpetual discounts in UI. + /// public bool Active { get; set; } + + /// + /// Percentage discount applied to the subscription (e.g., 20.0 for 20% off). + /// Null if this is an amount-based discount. + /// public decimal? PercentOff { get; set; } - public List AppliesTo { get; set; } + + /// + /// Fixed amount discount in USD (e.g., 14.00 for $14 off). + /// Converted from Stripe's cent-based values (1400 cents → $14.00). + /// Null if this is a percentage-based discount. + /// + public decimal? AmountOff { get; set; } + + /// + /// List of Stripe product IDs that this discount applies to (e.g., ["prod_premium", "prod_families"]). + /// + /// Null: discount applies to all products with no restrictions (AppliesTo not specified in Stripe). + /// Empty list: discount restricted to zero products (edge case - AppliesTo.Products = [] in Stripe). + /// Non-empty list: discount applies only to the specified product IDs. + /// + /// + public IReadOnlyList? AppliesTo { get; set; } } public class BillingSubscription { public BillingSubscription(Subscription sub) { - Status = sub.Status; - TrialStartDate = sub.TrialStart; - TrialEndDate = sub.TrialEnd; - var currentPeriod = sub.GetCurrentPeriod(); + Status = sub?.Status; + TrialStartDate = sub?.TrialStart; + TrialEndDate = sub?.TrialEnd; + var currentPeriod = sub?.GetCurrentPeriod(); if (currentPeriod != null) { var (start, end) = currentPeriod.Value; PeriodStartDate = start; PeriodEndDate = end; } - CancelledDate = sub.CanceledAt; - CancelAtEndDate = sub.CancelAtPeriodEnd; - Cancelled = sub.Status == "canceled" || sub.Status == "unpaid" || sub.Status == "incomplete_expired"; - if (sub.Items?.Data != null) + CancelledDate = sub?.CanceledAt; + CancelAtEndDate = sub?.CancelAtPeriodEnd ?? false; + var status = sub?.Status; + Cancelled = status == "canceled" || status == "unpaid" || status == "incomplete_expired"; + if (sub?.Items?.Data != null) { Items = sub.Items.Data.Select(i => new BillingSubscriptionItem(i)); } - CollectionMethod = sub.CollectionMethod; - GracePeriod = sub.CollectionMethod == "charge_automatically" + CollectionMethod = sub?.CollectionMethod; + GracePeriod = sub?.CollectionMethod == "charge_automatically" ? 14 : 30; } @@ -64,10 +124,10 @@ public class SubscriptionInfo public TimeSpan? PeriodDuration => PeriodEndDate - PeriodStartDate; public DateTime? CancelledDate { get; set; } public bool CancelAtEndDate { get; set; } - public string Status { get; set; } + public string? Status { get; set; } public bool Cancelled { get; set; } public IEnumerable Items { get; set; } = new List(); - public string CollectionMethod { get; set; } + public string? CollectionMethod { get; set; } public DateTime? SuspensionDate { get; set; } public DateTime? UnpaidPeriodEndDate { get; set; } public int GracePeriod { get; set; } @@ -80,7 +140,7 @@ public class SubscriptionInfo { ProductId = item.Plan.ProductId; Name = item.Plan.Nickname; - Amount = item.Plan.Amount.GetValueOrDefault() / 100M; + Amount = ConvertFromStripeMinorUnits(item.Plan.Amount) ?? 0; Interval = item.Plan.Interval; if (item.Metadata != null) @@ -90,15 +150,15 @@ public class SubscriptionInfo } Quantity = (int)item.Quantity; - SponsoredSubscriptionItem = Utilities.StaticStore.SponsoredPlans.Any(p => p.StripePlanId == item.Plan.Id); + SponsoredSubscriptionItem = item.Plan != null && Utilities.StaticStore.SponsoredPlans.Any(p => p.StripePlanId == item.Plan.Id); } public bool AddonSubscriptionItem { get; set; } - public string ProductId { get; set; } - public string Name { get; set; } + public string? ProductId { get; set; } + public string? Name { get; set; } public decimal Amount { get; set; } public int Quantity { get; set; } - public string Interval { get; set; } + public string? Interval { get; set; } public bool SponsoredSubscriptionItem { get; set; } } } @@ -109,7 +169,7 @@ public class SubscriptionInfo public BillingUpcomingInvoice(Invoice inv) { - Amount = inv.AmountDue / 100M; + Amount = ConvertFromStripeMinorUnits(inv.AmountDue) ?? 0; Date = inv.Created; } diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index ff99393955..5dd1ff50e7 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -643,9 +643,21 @@ public class StripePaymentService : IPaymentService var subscription = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, new SubscriptionGetOptions { Expand = ["customer.discount.coupon.applies_to", "discounts.coupon.applies_to", "test_clock"] }); + if (subscription == null) + { + return subscriptionInfo; + } + subscriptionInfo.Subscription = new SubscriptionInfo.BillingSubscription(subscription); - var discount = subscription.Customer.Discount ?? subscription.Discounts.FirstOrDefault(); + // Discount selection priority: + // 1. Customer-level discount (applies to all subscriptions for the customer) + // 2. First subscription-level discount (if multiple exist, FirstOrDefault() selects the first one) + // Note: When multiple subscription-level discounts exist, only the first one is used. + // This matches Stripe's behavior where the first discount in the list is applied. + // Defensive null checks: Even though we expand "customer" and "discounts", external APIs + // may not always return the expected data structure, so we use null-safe operators. + var discount = subscription.Customer?.Discount ?? subscription.Discounts?.FirstOrDefault(); if (discount != null) { diff --git a/test/Api.Test/Billing/Controllers/AccountsControllerTests.cs b/test/Api.Test/Billing/Controllers/AccountsControllerTests.cs new file mode 100644 index 0000000000..d84fddd282 --- /dev/null +++ b/test/Api.Test/Billing/Controllers/AccountsControllerTests.cs @@ -0,0 +1,800 @@ +using System.Security.Claims; +using Bit.Api.Billing.Controllers; +using Bit.Core; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Models.Business; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.KeyManagement.Queries.Interfaces; +using Bit.Core.Models.Business; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Test.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using NSubstitute; +using Stripe; +using Xunit; + +namespace Bit.Api.Test.Billing.Controllers; + +[SubscriptionInfoCustomize] +public class AccountsControllerTests : IDisposable +{ + private const string TestMilestone2CouponId = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount; + + private readonly IUserService _userService; + private readonly IFeatureService _featureService; + private readonly IPaymentService _paymentService; + private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; + private readonly IUserAccountKeysQuery _userAccountKeysQuery; + private readonly GlobalSettings _globalSettings; + private readonly AccountsController _sut; + + public AccountsControllerTests() + { + _userService = Substitute.For(); + _featureService = Substitute.For(); + _paymentService = Substitute.For(); + _twoFactorIsEnabledQuery = Substitute.For(); + _userAccountKeysQuery = Substitute.For(); + _globalSettings = new GlobalSettings { SelfHosted = false }; + + _sut = new AccountsController( + _userService, + _twoFactorIsEnabledQuery, + _userAccountKeysQuery, + _featureService + ); + } + + public void Dispose() + { + _sut?.Dispose(); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WhenFeatureFlagEnabled_IncludesDiscount( + User user, + SubscriptionInfo subscriptionInfo, + UserLicense license) + { + // Arrange + subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = TestMilestone2CouponId, + Active = true, + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "product1" } + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + + user.Gateway = GatewayType.Stripe; // User has payment gateway + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id); + Assert.Equal(20m, result.CustomerDiscount.PercentOff); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WhenFeatureFlagDisabled_ExcludesDiscount( + User user, + SubscriptionInfo subscriptionInfo, + UserLicense license) + { + // Arrange + subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = TestMilestone2CouponId, + Active = true, + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "product1" } + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(false); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + + user.Gateway = GatewayType.Stripe; // User has payment gateway + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert + Assert.NotNull(result); + Assert.Null(result.CustomerDiscount); // Should be null when feature flag is disabled + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WithNonMatchingCouponId_ExcludesDiscount( + User user, + SubscriptionInfo subscriptionInfo, + UserLicense license) + { + // Arrange + subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = "different-coupon-id", // Non-matching coupon ID + Active = true, + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "product1" } + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + + user.Gateway = GatewayType.Stripe; // User has payment gateway + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert + Assert.NotNull(result); + Assert.Null(result.CustomerDiscount); // Should be null when coupon ID doesn't match + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WhenSelfHosted_ReturnsBasicResponse(User user) + { + // Arrange + var selfHostedSettings = new GlobalSettings { SelfHosted = true }; + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + + // Act + var result = await _sut.GetSubscriptionAsync(selfHostedSettings, _paymentService); + + // Assert + Assert.NotNull(result); + Assert.Null(result.CustomerDiscount); + await _paymentService.DidNotReceive().GetSubscriptionAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WhenNoGateway_ExcludesDiscount(User user, UserLicense license) + { + // Arrange + user.Gateway = null; // No gateway configured + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _userService.GenerateLicenseAsync(user).Returns(license); + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert + Assert.NotNull(result); + Assert.Null(result.CustomerDiscount); // Should be null when no gateway + await _paymentService.DidNotReceive().GetSubscriptionAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WithInactiveDiscount_ExcludesDiscount( + User user, + SubscriptionInfo subscriptionInfo, + UserLicense license) + { + // Arrange + subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = TestMilestone2CouponId, + Active = false, // Inactive discount + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "product1" } + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + + user.Gateway = GatewayType.Stripe; // User has payment gateway + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert + Assert.NotNull(result); + Assert.Null(result.CustomerDiscount); // Should be null when discount is inactive + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_FullPipeline_ConvertsStripeDiscountToApiResponse( + User user, + UserLicense license) + { + // Arrange - Create a Stripe Discount object with real structure + var stripeDiscount = new Discount + { + Coupon = new Coupon + { + Id = TestMilestone2CouponId, + PercentOff = 25m, + AmountOff = 1400, // 1400 cents = $14.00 + AppliesTo = new CouponAppliesTo + { + Products = new List { "prod_premium", "prod_families" } + } + }, + End = null // Active discount + }; + + // Convert Stripe Discount to BillingCustomerDiscount (simulating what StripePaymentService does) + var billingDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount); + + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = billingDiscount + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + + user.Gateway = GatewayType.Stripe; + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert - Verify full pipeline conversion + Assert.NotNull(result); + Assert.NotNull(result.CustomerDiscount); + + // Verify Stripe data correctly converted to API response + Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id); + Assert.True(result.CustomerDiscount.Active); + Assert.Equal(25m, result.CustomerDiscount.PercentOff); + + // Verify cents-to-dollars conversion (1400 cents -> $14.00) + Assert.Equal(14.00m, result.CustomerDiscount.AmountOff); + + // Verify AppliesTo products are preserved + Assert.NotNull(result.CustomerDiscount.AppliesTo); + Assert.Equal(2, result.CustomerDiscount.AppliesTo.Count()); + Assert.Contains("prod_premium", result.CustomerDiscount.AppliesTo); + Assert.Contains("prod_families", result.CustomerDiscount.AppliesTo); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_FullPipeline_WithFeatureFlagToggle_ControlsVisibility( + User user, + UserLicense license) + { + // Arrange - Create Stripe Discount + var stripeDiscount = new Discount + { + Coupon = new Coupon + { + Id = TestMilestone2CouponId, + PercentOff = 20m + }, + End = null + }; + + var billingDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount); + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = billingDiscount + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + user.Gateway = GatewayType.Stripe; + + // Act & Assert - Feature flag ENABLED + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + var resultWithFlag = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + Assert.NotNull(resultWithFlag.CustomerDiscount); + + // Act & Assert - Feature flag DISABLED + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(false); + var resultWithoutFlag = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + Assert.Null(resultWithoutFlag.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_IntegrationTest_CompletePipelineFromStripeToApiResponse( + User user, + UserLicense license) + { + // Arrange - Create a real Stripe Discount object as it would come from Stripe API + var stripeDiscount = new Discount + { + Coupon = new Coupon + { + Id = TestMilestone2CouponId, + PercentOff = 30m, + AmountOff = 2000, // 2000 cents = $20.00 + AppliesTo = new CouponAppliesTo + { + Products = new List { "prod_premium", "prod_families", "prod_teams" } + } + }, + End = null // Active discount (no end date) + }; + + // Step 1: Map Stripe Discount through SubscriptionInfo.BillingCustomerDiscount + // This simulates what StripePaymentService.GetSubscriptionAsync does + var billingCustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount); + + // Verify the mapping worked correctly + Assert.Equal(TestMilestone2CouponId, billingCustomerDiscount.Id); + Assert.True(billingCustomerDiscount.Active); + Assert.Equal(30m, billingCustomerDiscount.PercentOff); + Assert.Equal(20.00m, billingCustomerDiscount.AmountOff); // Converted from cents + Assert.NotNull(billingCustomerDiscount.AppliesTo); + Assert.Equal(3, billingCustomerDiscount.AppliesTo.Count); + + // Step 2: Create SubscriptionInfo with the mapped discount + // This simulates what StripePaymentService returns + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = billingCustomerDiscount + }; + + // Step 3: Set up controller dependencies + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + user.Gateway = GatewayType.Stripe; + + // Act - Step 4: Call AccountsController.GetSubscriptionAsync + // This exercises the complete pipeline: + // - Retrieves subscriptionInfo from paymentService (with discount from Stripe) + // - Maps through SubscriptionInfo.BillingCustomerDiscount (already done above) + // - Filters in SubscriptionResponseModel constructor (based on feature flag, coupon ID, active status) + // - Returns via AccountsController + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert - Verify the complete pipeline worked end-to-end + Assert.NotNull(result); + Assert.NotNull(result.CustomerDiscount); + + // Verify Stripe Discount → SubscriptionInfo.BillingCustomerDiscount mapping + // (verified above, but confirming it made it through) + + // Verify SubscriptionInfo.BillingCustomerDiscount → SubscriptionResponseModel.BillingCustomerDiscount filtering + // The filter should pass because: + // - includeMilestone2Discount = true (feature flag enabled) + // - subscription.CustomerDiscount != null + // - subscription.CustomerDiscount.Id == Milestone2SubscriptionDiscount + // - subscription.CustomerDiscount.Active = true + Assert.Equal(TestMilestone2CouponId, result.CustomerDiscount.Id); + Assert.True(result.CustomerDiscount.Active); + Assert.Equal(30m, result.CustomerDiscount.PercentOff); + Assert.Equal(20.00m, result.CustomerDiscount.AmountOff); // Verify cents-to-dollars conversion + + // Verify AppliesTo products are preserved through the entire pipeline + Assert.NotNull(result.CustomerDiscount.AppliesTo); + Assert.Equal(3, result.CustomerDiscount.AppliesTo.Count()); + Assert.Contains("prod_premium", result.CustomerDiscount.AppliesTo); + Assert.Contains("prod_families", result.CustomerDiscount.AppliesTo); + Assert.Contains("prod_teams", result.CustomerDiscount.AppliesTo); + + // Verify the payment service was called correctly + await _paymentService.Received(1).GetSubscriptionAsync(user); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_IntegrationTest_MultipleDiscountsInSubscription_PrefersCustomerDiscount( + User user, + UserLicense license) + { + // Arrange - Create Stripe subscription with multiple discounts + // Customer discount should be preferred over subscription discounts + var customerDiscount = new Discount + { + Coupon = new Coupon + { + Id = TestMilestone2CouponId, + PercentOff = 30m, + AmountOff = null + }, + End = null + }; + + var subscriptionDiscount1 = new Discount + { + Coupon = new Coupon + { + Id = "other-coupon-1", + PercentOff = 10m + }, + End = null + }; + + var subscriptionDiscount2 = new Discount + { + Coupon = new Coupon + { + Id = "other-coupon-2", + PercentOff = 15m + }, + End = null + }; + + // Map through SubscriptionInfo.BillingCustomerDiscount + var billingCustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(customerDiscount); + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = billingCustomerDiscount + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + user.Gateway = GatewayType.Stripe; + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert - Should use customer discount, not subscription discounts + Assert.NotNull(result); + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(TestMilestone2CouponId, result.CustomerDiscount.Id); + Assert.Equal(30m, result.CustomerDiscount.PercentOff); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_IntegrationTest_BothPercentOffAndAmountOffPresent_HandlesEdgeCase( + User user, + UserLicense license) + { + // Arrange - Edge case: Stripe coupon with both PercentOff and AmountOff + // This tests the scenario mentioned in BillingCustomerDiscountTests.cs line 212-232 + var stripeDiscount = new Discount + { + Coupon = new Coupon + { + Id = TestMilestone2CouponId, + PercentOff = 25m, + AmountOff = 2000, // 2000 cents = $20.00 + AppliesTo = new CouponAppliesTo + { + Products = new List { "prod_premium" } + } + }, + End = null + }; + + // Map through SubscriptionInfo.BillingCustomerDiscount + var billingCustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount); + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = billingCustomerDiscount + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + user.Gateway = GatewayType.Stripe; + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert - Both values should be preserved through the pipeline + Assert.NotNull(result); + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(TestMilestone2CouponId, result.CustomerDiscount.Id); + Assert.Equal(25m, result.CustomerDiscount.PercentOff); + Assert.Equal(20.00m, result.CustomerDiscount.AmountOff); // Converted from cents + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_IntegrationTest_BillingSubscriptionMapsThroughPipeline( + User user, + UserLicense license) + { + // Arrange - Create Stripe subscription with subscription details + var stripeSubscription = new Subscription + { + Id = "sub_test123", + Status = "active", + TrialStart = DateTime.UtcNow.AddDays(-30), + TrialEnd = DateTime.UtcNow.AddDays(-20), + CanceledAt = null, + CancelAtPeriodEnd = false, + CollectionMethod = "charge_automatically" + }; + + // Map through SubscriptionInfo.BillingSubscription + var billingSubscription = new SubscriptionInfo.BillingSubscription(stripeSubscription); + var subscriptionInfo = new SubscriptionInfo + { + Subscription = billingSubscription, + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = TestMilestone2CouponId, + Active = true, + PercentOff = 20m + } + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + user.Gateway = GatewayType.Stripe; + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert - Verify BillingSubscription mapped through pipeline + Assert.NotNull(result); + Assert.NotNull(result.Subscription); + Assert.Equal("active", result.Subscription.Status); + Assert.Equal(14, result.Subscription.GracePeriod); // charge_automatically = 14 days + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_IntegrationTest_BillingUpcomingInvoiceMapsThroughPipeline( + User user, + UserLicense license) + { + // Arrange - Create Stripe invoice for upcoming invoice + var stripeInvoice = new Invoice + { + AmountDue = 2000, // 2000 cents = $20.00 + Created = DateTime.UtcNow.AddDays(1) + }; + + // Map through SubscriptionInfo.BillingUpcomingInvoice + var billingUpcomingInvoice = new SubscriptionInfo.BillingUpcomingInvoice(stripeInvoice); + var subscriptionInfo = new SubscriptionInfo + { + UpcomingInvoice = billingUpcomingInvoice, + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = TestMilestone2CouponId, + Active = true, + PercentOff = 20m + } + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + user.Gateway = GatewayType.Stripe; + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert - Verify BillingUpcomingInvoice mapped through pipeline + Assert.NotNull(result); + Assert.NotNull(result.UpcomingInvoice); + Assert.Equal(20.00m, result.UpcomingInvoice.Amount); // Converted from cents + Assert.NotNull(result.UpcomingInvoice.Date); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_IntegrationTest_CompletePipelineWithAllComponents( + User user, + UserLicense license) + { + // Arrange - Complete Stripe objects for full pipeline test + var stripeDiscount = new Discount + { + Coupon = new Coupon + { + Id = TestMilestone2CouponId, + PercentOff = 20m, + AmountOff = 1000, // $10.00 + AppliesTo = new CouponAppliesTo + { + Products = new List { "prod_premium", "prod_families" } + } + }, + End = null + }; + + var stripeSubscription = new Subscription + { + Id = "sub_test123", + Status = "active", + CollectionMethod = "charge_automatically" + }; + + var stripeInvoice = new Invoice + { + AmountDue = 1500, // $15.00 + Created = DateTime.UtcNow.AddDays(7) + }; + + // Map through SubscriptionInfo (simulating StripePaymentService) + var billingCustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount); + var billingSubscription = new SubscriptionInfo.BillingSubscription(stripeSubscription); + var billingUpcomingInvoice = new SubscriptionInfo.BillingUpcomingInvoice(stripeInvoice); + + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = billingCustomerDiscount, + Subscription = billingSubscription, + UpcomingInvoice = billingUpcomingInvoice + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + user.Gateway = GatewayType.Stripe; + + // Act - Full pipeline: Stripe → SubscriptionInfo → SubscriptionResponseModel → API response + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert - Verify all components mapped correctly through the pipeline + Assert.NotNull(result); + + // Verify discount + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(TestMilestone2CouponId, result.CustomerDiscount.Id); + Assert.Equal(20m, result.CustomerDiscount.PercentOff); + Assert.Equal(10.00m, result.CustomerDiscount.AmountOff); + Assert.NotNull(result.CustomerDiscount.AppliesTo); + Assert.Equal(2, result.CustomerDiscount.AppliesTo.Count()); + + // Verify subscription + Assert.NotNull(result.Subscription); + Assert.Equal("active", result.Subscription.Status); + Assert.Equal(14, result.Subscription.GracePeriod); + + // Verify upcoming invoice + Assert.NotNull(result.UpcomingInvoice); + Assert.Equal(15.00m, result.UpcomingInvoice.Amount); + Assert.NotNull(result.UpcomingInvoice.Date); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_SelfHosted_WithDiscountFlagEnabled_NeverIncludesDiscount(User user) + { + // Arrange - Self-hosted user with discount flag enabled (should still return null) + var selfHostedSettings = new GlobalSettings { SelfHosted = true }; + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); // Flag enabled + + // Act + var result = await _sut.GetSubscriptionAsync(selfHostedSettings, _paymentService); + + // Assert - Should never include discount for self-hosted, even with flag enabled + Assert.NotNull(result); + Assert.Null(result.CustomerDiscount); + await _paymentService.DidNotReceive().GetSubscriptionAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_NullGateway_WithDiscountFlagEnabled_NeverIncludesDiscount( + User user, + UserLicense license) + { + // Arrange - User with null gateway and discount flag enabled (should still return null) + user.Gateway = null; // No gateway configured + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _userService.GenerateLicenseAsync(user).Returns(license); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); // Flag enabled + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert - Should never include discount when no gateway, even with flag enabled + Assert.NotNull(result); + Assert.Null(result.CustomerDiscount); + await _paymentService.DidNotReceive().GetSubscriptionAsync(Arg.Any()); + } +} diff --git a/test/Api.Test/Models/Response/SubscriptionResponseModelTests.cs b/test/Api.Test/Models/Response/SubscriptionResponseModelTests.cs new file mode 100644 index 0000000000..051a66bbd3 --- /dev/null +++ b/test/Api.Test/Models/Response/SubscriptionResponseModelTests.cs @@ -0,0 +1,400 @@ +using Bit.Api.Models.Response; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Models.Business; +using Bit.Core.Entities; +using Bit.Core.Models.Business; +using Bit.Test.Common.AutoFixture.Attributes; +using Stripe; +using Xunit; + +namespace Bit.Api.Test.Models.Response; + +public class SubscriptionResponseModelTests +{ + [Theory] + [BitAutoData] + public void Constructor_IncludeMilestone2DiscountTrueMatchingCouponId_ReturnsDiscount( + User user, + UserLicense license) + { + // Arrange + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, // Matching coupon ID + Active = true, + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "product1" } + } + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true); + + // Assert + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id); + Assert.True(result.CustomerDiscount.Active); + Assert.Equal(20m, result.CustomerDiscount.PercentOff); + Assert.Null(result.CustomerDiscount.AmountOff); + Assert.NotNull(result.CustomerDiscount.AppliesTo); + Assert.Single(result.CustomerDiscount.AppliesTo); + } + + [Theory] + [BitAutoData] + public void Constructor_IncludeMilestone2DiscountTrueNonMatchingCouponId_ReturnsNull( + User user, + UserLicense license) + { + // Arrange + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = "different-coupon-id", // Non-matching coupon ID + Active = true, + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "product1" } + } + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true); + + // Assert + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_IncludeMilestone2DiscountFalseMatchingCouponId_ReturnsNull( + User user, + UserLicense license) + { + // Arrange + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, // Matching coupon ID + Active = true, + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "product1" } + } + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: false); + + // Assert - Should be null because includeMilestone2Discount is false + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_NullCustomerDiscount_ReturnsNull( + User user, + UserLicense license) + { + // Arrange + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = null + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true); + + // Assert + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_AmountOffDiscountMatchingCouponId_ReturnsDiscount( + User user, + UserLicense license) + { + // Arrange + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, + Active = true, + PercentOff = null, + AmountOff = 14.00m, // Already converted from cents in BillingCustomerDiscount + AppliesTo = new List() + } + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true); + + // Assert + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id); + Assert.Null(result.CustomerDiscount.PercentOff); + Assert.Equal(14.00m, result.CustomerDiscount.AmountOff); + } + + [Theory] + [BitAutoData] + public void Constructor_DefaultIncludeMilestone2DiscountParameter_ReturnsNull( + User user, + UserLicense license) + { + // Arrange + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, + Active = true, + PercentOff = 20m + } + }; + + // Act - Using default parameter (includeMilestone2Discount defaults to false) + var result = new SubscriptionResponseModel(user, subscriptionInfo, license); + + // Assert + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_NullDiscountIdIncludeMilestone2DiscountTrue_ReturnsNull( + User user, + UserLicense license) + { + // Arrange + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = null, // Null discount ID + Active = true, + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "product1" } + } + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true); + + // Assert + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_MatchingCouponIdInactiveDiscount_ReturnsNull( + User user, + UserLicense license) + { + // Arrange + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, // Matching coupon ID + Active = false, // Inactive discount + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "product1" } + } + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true); + + // Assert + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_UserOnly_SetsBasicProperties(User user) + { + // Arrange + user.Storage = 5368709120; // 5 GB in bytes + user.MaxStorageGb = (short)10; + user.PremiumExpirationDate = DateTime.UtcNow.AddMonths(12); + + // Act + var result = new SubscriptionResponseModel(user); + + // Assert + Assert.NotNull(result.StorageName); + Assert.Equal(5.0, result.StorageGb); + Assert.Equal((short)10, result.MaxStorageGb); + Assert.Equal(user.PremiumExpirationDate, result.Expiration); + Assert.Null(result.License); + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_UserAndLicense_IncludesLicense(User user, UserLicense license) + { + // Arrange + user.Storage = 1073741824; // 1 GB in bytes + user.MaxStorageGb = (short)5; + + // Act + var result = new SubscriptionResponseModel(user, license); + + // Assert + Assert.NotNull(result.License); + Assert.Equal(license, result.License); + Assert.Equal(1.0, result.StorageGb); + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_NullStorage_SetsStorageToZero(User user) + { + // Arrange + user.Storage = null; + + // Act + var result = new SubscriptionResponseModel(user); + + // Assert + Assert.Null(result.StorageName); + Assert.Equal(0, result.StorageGb); + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_NullLicense_ExcludesLicense(User user) + { + // Act + var result = new SubscriptionResponseModel(user, null); + + // Assert + Assert.Null(result.License); + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_BothPercentOffAndAmountOffPresent_HandlesEdgeCase( + User user, + UserLicense license) + { + // Arrange - Edge case: Both PercentOff and AmountOff present + // This tests the scenario where Stripe coupon has both discount types + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, + Active = true, + PercentOff = 25m, + AmountOff = 20.00m, // Already converted from cents + AppliesTo = new List { "prod_premium" } + } + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true); + + // Assert - Both values should be preserved + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id); + Assert.Equal(25m, result.CustomerDiscount.PercentOff); + Assert.Equal(20.00m, result.CustomerDiscount.AmountOff); + Assert.NotNull(result.CustomerDiscount.AppliesTo); + Assert.Single(result.CustomerDiscount.AppliesTo); + } + + [Theory] + [BitAutoData] + public void Constructor_WithSubscriptionAndInvoice_MapsAllProperties( + User user, + UserLicense license) + { + // Arrange - Test with Subscription, UpcomingInvoice, and CustomerDiscount + var stripeSubscription = new Subscription + { + Id = "sub_test123", + Status = "active", + CollectionMethod = "charge_automatically" + }; + + var stripeInvoice = new Invoice + { + AmountDue = 1500, // 1500 cents = $15.00 + Created = DateTime.UtcNow.AddDays(7) + }; + + var subscriptionInfo = new SubscriptionInfo + { + Subscription = new SubscriptionInfo.BillingSubscription(stripeSubscription), + UpcomingInvoice = new SubscriptionInfo.BillingUpcomingInvoice(stripeInvoice), + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, + Active = true, + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "prod_premium" } + } + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true); + + // Assert - Verify all properties are mapped correctly + Assert.NotNull(result.Subscription); + Assert.Equal("active", result.Subscription.Status); + Assert.Equal(14, result.Subscription.GracePeriod); // charge_automatically = 14 days + + Assert.NotNull(result.UpcomingInvoice); + Assert.Equal(15.00m, result.UpcomingInvoice.Amount); + Assert.NotNull(result.UpcomingInvoice.Date); + + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id); + Assert.True(result.CustomerDiscount.Active); + Assert.Equal(20m, result.CustomerDiscount.PercentOff); + } + + [Theory] + [BitAutoData] + public void Constructor_WithNullSubscriptionAndInvoice_HandlesNullsGracefully( + User user, + UserLicense license) + { + // Arrange - Test with null Subscription and UpcomingInvoice + var subscriptionInfo = new SubscriptionInfo + { + Subscription = null, + UpcomingInvoice = null, + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, + Active = true, + PercentOff = 20m + } + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true); + + // Assert - Null Subscription and UpcomingInvoice should be handled gracefully + Assert.Null(result.Subscription); + Assert.Null(result.UpcomingInvoice); + Assert.NotNull(result.CustomerDiscount); + } +} diff --git a/test/Core.Test/Models/Business/BillingCustomerDiscountTests.cs b/test/Core.Test/Models/Business/BillingCustomerDiscountTests.cs new file mode 100644 index 0000000000..6dbe829da5 --- /dev/null +++ b/test/Core.Test/Models/Business/BillingCustomerDiscountTests.cs @@ -0,0 +1,497 @@ +using Bit.Core.Models.Business; +using Bit.Test.Common.AutoFixture.Attributes; +using Stripe; +using Xunit; + +namespace Bit.Core.Test.Models.Business; + +public class BillingCustomerDiscountTests +{ + [Theory] + [BitAutoData] + public void Constructor_PercentageDiscount_SetsIdActivePercentOffAndAppliesTo(string couponId) + { + // Arrange + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = 25.5m, + AmountOff = null, + AppliesTo = new CouponAppliesTo + { + Products = new List { "product1", "product2" } + } + }, + End = null // Active discount + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Equal(couponId, result.Id); + Assert.True(result.Active); + Assert.Equal(25.5m, result.PercentOff); + Assert.Null(result.AmountOff); + Assert.NotNull(result.AppliesTo); + Assert.Equal(2, result.AppliesTo.Count); + Assert.Contains("product1", result.AppliesTo); + Assert.Contains("product2", result.AppliesTo); + } + + [Theory] + [BitAutoData] + public void Constructor_AmountDiscount_ConvertsFromCentsToDollars(string couponId) + { + // Arrange - Stripe sends 1400 cents for $14.00 + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = null, + AmountOff = 1400, // 1400 cents + AppliesTo = new CouponAppliesTo + { + Products = new List() + } + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Equal(couponId, result.Id); + Assert.True(result.Active); + Assert.Null(result.PercentOff); + Assert.Equal(14.00m, result.AmountOff); // Converted to dollars + Assert.NotNull(result.AppliesTo); + Assert.Empty(result.AppliesTo); + } + + [Theory] + [BitAutoData] + public void Constructor_InactiveDiscount_SetsActiveToFalse(string couponId) + { + // Arrange + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = 15m + }, + End = DateTime.UtcNow.AddDays(-1) // Expired discount + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Equal(couponId, result.Id); + Assert.False(result.Active); + Assert.Equal(15m, result.PercentOff); + } + + [Fact] + public void Constructor_NullCoupon_SetsDiscountPropertiesToNull() + { + // Arrange + var discount = new Discount + { + Coupon = null, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Null(result.Id); + Assert.True(result.Active); + Assert.Null(result.PercentOff); + Assert.Null(result.AmountOff); + Assert.Null(result.AppliesTo); + } + + [Theory] + [BitAutoData] + public void Constructor_NullAmountOff_SetsAmountOffToNull(string couponId) + { + // Arrange + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = 10m, + AmountOff = null + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Null(result.AmountOff); + } + + [Theory] + [BitAutoData] + public void Constructor_ZeroAmountOff_ConvertsCorrectly(string couponId) + { + // Arrange + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + AmountOff = 0 + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Equal(0m, result.AmountOff); + } + + [Theory] + [BitAutoData] + public void Constructor_LargeAmountOff_ConvertsCorrectly(string couponId) + { + // Arrange - $100.00 discount + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + AmountOff = 10000 // 10000 cents = $100.00 + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Equal(100.00m, result.AmountOff); + } + + [Theory] + [BitAutoData] + public void Constructor_SmallAmountOff_ConvertsCorrectly(string couponId) + { + // Arrange - $0.50 discount + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + AmountOff = 50 // 50 cents = $0.50 + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Equal(0.50m, result.AmountOff); + } + + [Theory] + [BitAutoData] + public void Constructor_BothDiscountTypes_SetsPercentOffAndAmountOff(string couponId) + { + // Arrange - Coupon with both percentage and amount (edge case) + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = 20m, + AmountOff = 500 // $5.00 + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Equal(20m, result.PercentOff); + Assert.Equal(5.00m, result.AmountOff); + } + + [Theory] + [BitAutoData] + public void Constructor_WithNullAppliesTo_SetsAppliesToNull(string couponId) + { + // Arrange + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = 10m, + AppliesTo = null + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Null(result.AppliesTo); + } + + [Theory] + [BitAutoData] + public void Constructor_WithNullProductsList_SetsAppliesToNull(string couponId) + { + // Arrange + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = 10m, + AppliesTo = new CouponAppliesTo + { + Products = null + } + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Null(result.AppliesTo); + } + + [Theory] + [BitAutoData] + public void Constructor_WithDecimalAmountOff_RoundsCorrectly(string couponId) + { + // Arrange - 1425 cents = $14.25 + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + AmountOff = 1425 + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Equal(14.25m, result.AmountOff); + } + + [Fact] + public void Constructor_DefaultConstructor_InitializesAllPropertiesToNullOrFalse() + { + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(); + + // Assert + Assert.Null(result.Id); + Assert.False(result.Active); + Assert.Null(result.PercentOff); + Assert.Null(result.AmountOff); + Assert.Null(result.AppliesTo); + } + + [Theory] + [BitAutoData] + public void Constructor_WithFutureEndDate_SetsActiveToFalse(string couponId) + { + // Arrange - Discount expires in the future + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = 20m + }, + End = DateTime.UtcNow.AddDays(30) // Expires in 30 days + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.False(result.Active); // Should be inactive because End is not null + } + + [Theory] + [BitAutoData] + public void Constructor_WithPastEndDate_SetsActiveToFalse(string couponId) + { + // Arrange - Discount already expired + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = 20m + }, + End = DateTime.UtcNow.AddDays(-30) // Expired 30 days ago + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.False(result.Active); // Should be inactive because End is not null + } + + [Fact] + public void Constructor_WithNullCouponId_SetsIdToNull() + { + // Arrange + var discount = new Discount + { + Coupon = new Coupon + { + Id = null, + PercentOff = 20m + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Null(result.Id); + Assert.True(result.Active); + Assert.Equal(20m, result.PercentOff); + } + + [Theory] + [BitAutoData] + public void Constructor_WithNullPercentOff_SetsPercentOffToNull(string couponId) + { + // Arrange + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = null, + AmountOff = 1000 + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Null(result.PercentOff); + Assert.Equal(10.00m, result.AmountOff); + } + + [Fact] + public void Constructor_WithCompleteStripeDiscount_MapsAllProperties() + { + // Arrange - Comprehensive test with all Stripe Discount properties set + var discount = new Discount + { + Coupon = new Coupon + { + Id = "premium_discount_2024", + PercentOff = 25m, + AmountOff = 1500, // $15.00 + AppliesTo = new CouponAppliesTo + { + Products = new List { "prod_premium", "prod_family", "prod_teams" } + } + }, + End = null // Active + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert - Verify all properties mapped correctly + Assert.Equal("premium_discount_2024", result.Id); + Assert.True(result.Active); + Assert.Equal(25m, result.PercentOff); + Assert.Equal(15.00m, result.AmountOff); + Assert.NotNull(result.AppliesTo); + Assert.Equal(3, result.AppliesTo.Count); + Assert.Contains("prod_premium", result.AppliesTo); + Assert.Contains("prod_family", result.AppliesTo); + Assert.Contains("prod_teams", result.AppliesTo); + } + + [Fact] + public void Constructor_WithMinimalStripeDiscount_HandlesNullsGracefully() + { + // Arrange - Minimal Stripe Discount with most properties null + var discount = new Discount + { + Coupon = new Coupon + { + Id = null, + PercentOff = null, + AmountOff = null, + AppliesTo = null + }, + End = DateTime.UtcNow.AddDays(10) // Has end date + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert - Should handle all nulls gracefully + Assert.Null(result.Id); + Assert.False(result.Active); + Assert.Null(result.PercentOff); + Assert.Null(result.AmountOff); + Assert.Null(result.AppliesTo); + } + + [Theory] + [BitAutoData] + public void Constructor_WithEmptyProductsList_PreservesEmptyList(string couponId) + { + // Arrange + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = 10m, + AppliesTo = new CouponAppliesTo + { + Products = new List() // Empty but not null + } + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.NotNull(result.AppliesTo); + Assert.Empty(result.AppliesTo); + } +} diff --git a/test/Core.Test/Models/Business/SubscriptionInfoTests.cs b/test/Core.Test/Models/Business/SubscriptionInfoTests.cs new file mode 100644 index 0000000000..ef6a61ad5d --- /dev/null +++ b/test/Core.Test/Models/Business/SubscriptionInfoTests.cs @@ -0,0 +1,125 @@ +using Bit.Core.Models.Business; +using Stripe; +using Xunit; + +namespace Bit.Core.Test.Models.Business; + +public class SubscriptionInfoTests +{ + [Fact] + public void BillingSubscriptionItem_NullPlan_HandlesGracefully() + { + // Arrange - SubscriptionItem with null Plan + var subscriptionItem = new SubscriptionItem + { + Plan = null, + Quantity = 1 + }; + + // Act + var result = new SubscriptionInfo.BillingSubscription.BillingSubscriptionItem(subscriptionItem); + + // Assert - Should handle null Plan gracefully + Assert.Null(result.ProductId); + Assert.Null(result.Name); + Assert.Equal(0m, result.Amount); // Defaults to 0 when Plan is null + Assert.Null(result.Interval); + Assert.Equal(1, result.Quantity); + Assert.False(result.SponsoredSubscriptionItem); + Assert.False(result.AddonSubscriptionItem); + } + + [Fact] + public void BillingSubscriptionItem_NullAmount_SetsToZero() + { + // Arrange - SubscriptionItem with Plan but null Amount + var subscriptionItem = new SubscriptionItem + { + Plan = new Plan + { + ProductId = "prod_test", + Nickname = "Test Plan", + Amount = null, // Null amount + Interval = "month" + }, + Quantity = 1 + }; + + // Act + var result = new SubscriptionInfo.BillingSubscription.BillingSubscriptionItem(subscriptionItem); + + // Assert - Should default to 0 when Amount is null + Assert.Equal("prod_test", result.ProductId); + Assert.Equal("Test Plan", result.Name); + Assert.Equal(0m, result.Amount); // Business rule: defaults to 0 when null + Assert.Equal("month", result.Interval); + Assert.Equal(1, result.Quantity); + } + + [Fact] + public void BillingSubscriptionItem_ZeroAmount_PreservesZero() + { + // Arrange - SubscriptionItem with Plan and zero Amount + var subscriptionItem = new SubscriptionItem + { + Plan = new Plan + { + ProductId = "prod_test", + Nickname = "Test Plan", + Amount = 0, // Zero amount (0 cents) + Interval = "month" + }, + Quantity = 1 + }; + + // Act + var result = new SubscriptionInfo.BillingSubscription.BillingSubscriptionItem(subscriptionItem); + + // Assert - Should preserve zero amount + Assert.Equal("prod_test", result.ProductId); + Assert.Equal("Test Plan", result.Name); + Assert.Equal(0m, result.Amount); // Zero amount preserved + Assert.Equal("month", result.Interval); + } + + [Fact] + public void BillingUpcomingInvoice_ZeroAmountDue_ConvertsToZero() + { + // Arrange - Invoice with zero AmountDue + // Note: Stripe's Invoice.AmountDue is non-nullable long, so we test with 0 + // The null-coalescing operator (?? 0) in the constructor handles the case where + // ConvertFromStripeMinorUnits returns null, but since AmountDue is non-nullable, + // this test verifies the conversion path works correctly for zero values + var invoice = new Invoice + { + AmountDue = 0, // Zero amount due (0 cents) + Created = DateTime.UtcNow + }; + + // Act + var result = new SubscriptionInfo.BillingUpcomingInvoice(invoice); + + // Assert - Should convert zero correctly + Assert.Equal(0m, result.Amount); + Assert.NotNull(result.Date); + } + + [Fact] + public void BillingUpcomingInvoice_ValidAmountDue_ConvertsCorrectly() + { + // Arrange - Invoice with valid AmountDue + var invoice = new Invoice + { + AmountDue = 2500, // 2500 cents = $25.00 + Created = DateTime.UtcNow + }; + + // Act + var result = new SubscriptionInfo.BillingUpcomingInvoice(invoice); + + // Assert - Should convert correctly + Assert.Equal(25.00m, result.Amount); // Converted from cents + Assert.NotNull(result.Date); + } +} + diff --git a/test/Core.Test/Services/StripePaymentServiceTests.cs b/test/Core.Test/Services/StripePaymentServiceTests.cs index dd342bd153..863fe716d4 100644 --- a/test/Core.Test/Services/StripePaymentServiceTests.cs +++ b/test/Core.Test/Services/StripePaymentServiceTests.cs @@ -3,6 +3,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models.StaticStore.Plans; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Tax.Requests; +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Services; using Bit.Test.Common.AutoFixture; @@ -515,4 +516,399 @@ public class StripePaymentServiceTests options.CustomerDetails.TaxExempt == StripeConstants.TaxExempt.Reverse )); } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WithCustomerDiscount_ReturnsDiscountFromCustomer( + SutProvider sutProvider, + User subscriber) + { + // Arrange + subscriber.Gateway = GatewayType.Stripe; + subscriber.GatewayCustomerId = "cus_test123"; + subscriber.GatewaySubscriptionId = "sub_test123"; + + var customerDiscount = new Discount + { + Coupon = new Coupon + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, + PercentOff = 20m, + AmountOff = 1400 + }, + End = null + }; + + var subscription = new Subscription + { + Id = "sub_test123", + Status = "active", + CollectionMethod = "charge_automatically", + Customer = new Customer + { + Discount = customerDiscount + }, + Discounts = new List(), // Empty list + Items = new StripeList { Data = [] } + }; + + sutProvider.GetDependency() + .SubscriptionGetAsync( + subscriber.GatewaySubscriptionId, + Arg.Any()) + .Returns(subscription); + + // Act + var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber); + + // Assert + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id); + Assert.Equal(20m, result.CustomerDiscount.PercentOff); + Assert.Equal(14.00m, result.CustomerDiscount.AmountOff); // Converted from cents + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WithoutCustomerDiscount_FallsBackToSubscriptionDiscounts( + SutProvider sutProvider, + User subscriber) + { + // Arrange + subscriber.Gateway = GatewayType.Stripe; + subscriber.GatewayCustomerId = "cus_test123"; + subscriber.GatewaySubscriptionId = "sub_test123"; + + var subscriptionDiscount = new Discount + { + Coupon = new Coupon + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, + PercentOff = 15m, + AmountOff = null + }, + End = null + }; + + var subscription = new Subscription + { + Id = "sub_test123", + Status = "active", + CollectionMethod = "charge_automatically", + Customer = new Customer + { + Discount = null // No customer discount + }, + Discounts = new List { subscriptionDiscount }, + Items = new StripeList { Data = [] } + }; + + sutProvider.GetDependency() + .SubscriptionGetAsync( + subscriber.GatewaySubscriptionId, + Arg.Any()) + .Returns(subscription); + + // Act + var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber); + + // Assert - Should use subscription discount as fallback + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id); + Assert.Equal(15m, result.CustomerDiscount.PercentOff); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WithBothDiscounts_PrefersCustomerDiscount( + SutProvider sutProvider, + User subscriber) + { + // Arrange + subscriber.Gateway = GatewayType.Stripe; + subscriber.GatewayCustomerId = "cus_test123"; + subscriber.GatewaySubscriptionId = "sub_test123"; + + var customerDiscount = new Discount + { + Coupon = new Coupon + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, + PercentOff = 25m + }, + End = null + }; + + var subscriptionDiscount = new Discount + { + Coupon = new Coupon + { + Id = "different-coupon-id", + PercentOff = 10m + }, + End = null + }; + + var subscription = new Subscription + { + Id = "sub_test123", + Status = "active", + CollectionMethod = "charge_automatically", + Customer = new Customer + { + Discount = customerDiscount // Should prefer this + }, + Discounts = new List { subscriptionDiscount }, + Items = new StripeList { Data = [] } + }; + + sutProvider.GetDependency() + .SubscriptionGetAsync( + subscriber.GatewaySubscriptionId, + Arg.Any()) + .Returns(subscription); + + // Act + var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber); + + // Assert - Should prefer customer discount over subscription discount + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id); + Assert.Equal(25m, result.CustomerDiscount.PercentOff); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WithNoDiscounts_ReturnsNullDiscount( + SutProvider sutProvider, + User subscriber) + { + // Arrange + subscriber.Gateway = GatewayType.Stripe; + subscriber.GatewayCustomerId = "cus_test123"; + subscriber.GatewaySubscriptionId = "sub_test123"; + + var subscription = new Subscription + { + Id = "sub_test123", + Status = "active", + CollectionMethod = "charge_automatically", + Customer = new Customer + { + Discount = null + }, + Discounts = new List(), // Empty list, no discounts + Items = new StripeList { Data = [] } + }; + + sutProvider.GetDependency() + .SubscriptionGetAsync( + subscriber.GatewaySubscriptionId, + Arg.Any()) + .Returns(subscription); + + // Act + var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber); + + // Assert + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WithMultipleSubscriptionDiscounts_SelectsFirstDiscount( + SutProvider sutProvider, + User subscriber) + { + // Arrange - Multiple subscription-level discounts, no customer discount + subscriber.Gateway = GatewayType.Stripe; + subscriber.GatewayCustomerId = "cus_test123"; + subscriber.GatewaySubscriptionId = "sub_test123"; + + var firstDiscount = new Discount + { + Coupon = new Coupon + { + Id = "coupon-10-percent", + PercentOff = 10m + }, + End = null + }; + + var secondDiscount = new Discount + { + Coupon = new Coupon + { + Id = "coupon-20-percent", + PercentOff = 20m + }, + End = null + }; + + var subscription = new Subscription + { + Id = "sub_test123", + Status = "active", + CollectionMethod = "charge_automatically", + Customer = new Customer + { + Discount = null // No customer discount + }, + // Multiple subscription discounts - FirstOrDefault() should select the first one + Discounts = new List { firstDiscount, secondDiscount }, + Items = new StripeList { Data = [] } + }; + + sutProvider.GetDependency() + .SubscriptionGetAsync( + subscriber.GatewaySubscriptionId, + Arg.Any()) + .Returns(subscription); + + // Act + var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber); + + // Assert - Should select the first discount from the list (FirstOrDefault() behavior) + Assert.NotNull(result.CustomerDiscount); + Assert.Equal("coupon-10-percent", result.CustomerDiscount.Id); + Assert.Equal(10m, result.CustomerDiscount.PercentOff); + // Verify the second discount was not selected + Assert.NotEqual("coupon-20-percent", result.CustomerDiscount.Id); + Assert.NotEqual(20m, result.CustomerDiscount.PercentOff); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WithNullCustomer_HandlesGracefully( + SutProvider sutProvider, + User subscriber) + { + // Arrange - Subscription with null Customer (defensive null check scenario) + subscriber.Gateway = GatewayType.Stripe; + subscriber.GatewayCustomerId = "cus_test123"; + subscriber.GatewaySubscriptionId = "sub_test123"; + + var subscription = new Subscription + { + Id = "sub_test123", + Status = "active", + CollectionMethod = "charge_automatically", + Customer = null, // Customer not expanded or null + Discounts = new List(), // Empty discounts + Items = new StripeList { Data = [] } + }; + + sutProvider.GetDependency() + .SubscriptionGetAsync( + subscriber.GatewaySubscriptionId, + Arg.Any()) + .Returns(subscription); + + // Act + var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber); + + // Assert - Should handle null Customer gracefully without throwing NullReferenceException + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WithNullDiscounts_HandlesGracefully( + SutProvider sutProvider, + User subscriber) + { + // Arrange - Subscription with null Discounts (defensive null check scenario) + subscriber.Gateway = GatewayType.Stripe; + subscriber.GatewayCustomerId = "cus_test123"; + subscriber.GatewaySubscriptionId = "sub_test123"; + + var subscription = new Subscription + { + Id = "sub_test123", + Status = "active", + CollectionMethod = "charge_automatically", + Customer = new Customer + { + Discount = null // No customer discount + }, + Discounts = null, // Discounts not expanded or null + Items = new StripeList { Data = [] } + }; + + sutProvider.GetDependency() + .SubscriptionGetAsync( + subscriber.GatewaySubscriptionId, + Arg.Any()) + .Returns(subscription); + + // Act + var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber); + + // Assert - Should handle null Discounts gracefully without throwing NullReferenceException + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_VerifiesCorrectExpandOptions( + SutProvider sutProvider, + User subscriber) + { + // Arrange + subscriber.Gateway = GatewayType.Stripe; + subscriber.GatewayCustomerId = "cus_test123"; + subscriber.GatewaySubscriptionId = "sub_test123"; + + var subscription = new Subscription + { + Id = "sub_test123", + Status = "active", + CollectionMethod = "charge_automatically", + Customer = new Customer { Discount = null }, + Discounts = new List(), // Empty list + Items = new StripeList { Data = [] } + }; + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter + .SubscriptionGetAsync( + Arg.Any(), + Arg.Any()) + .Returns(subscription); + + // Act + await sutProvider.Sut.GetSubscriptionAsync(subscriber); + + // Assert - Verify expand options are correct + await stripeAdapter.Received(1).SubscriptionGetAsync( + subscriber.GatewaySubscriptionId, + Arg.Is(o => + o.Expand.Contains("customer.discount.coupon.applies_to") && + o.Expand.Contains("discounts.coupon.applies_to") && + o.Expand.Contains("test_clock"))); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WithEmptyGatewaySubscriptionId_ReturnsEmptySubscriptionInfo( + SutProvider sutProvider, + User subscriber) + { + // Arrange + subscriber.GatewaySubscriptionId = null; + + // Act + var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber); + + // Assert + Assert.NotNull(result); + Assert.Null(result.Subscription); + Assert.Null(result.CustomerDiscount); + Assert.Null(result.UpcomingInvoice); + + // Verify no Stripe API calls were made + await sutProvider.GetDependency() + .DidNotReceive() + .SubscriptionGetAsync(Arg.Any(), Arg.Any()); + } } From 7f04830f771e5ac0c5deaa45bb697892a57a4173 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 12 Nov 2025 14:49:15 -0600 Subject: [PATCH 21/77] [deps]: Update actions/setup-node action to v6 (#6499) --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 49cd81d28f..baba0fb776 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -122,7 +122,7 @@ jobs: uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 - name: Set up Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: cache: "npm" cache-dependency-path: "**/package-lock.json" From a836ada6a7ecee7f63dc6adb20517fb3e6374729 Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Wed, 12 Nov 2025 17:56:17 -0500 Subject: [PATCH 22/77] [PM-23059] Provider Users who are also Organization Members cannot edit or delete items via Admin Console when admins can manage all items (#6573) * removed providers check * Fixed lint issues --- src/Api/Vault/Controllers/CiphersController.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 46d8332926..0983225f84 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -402,8 +402,9 @@ public class CiphersController : Controller { var org = _currentContext.GetOrganization(organizationId); - // If we're not an "admin" or if we're not a provider user we don't need to check the ciphers - if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or { Permissions.EditAnyCollection: true }) || await _currentContext.ProviderUserForOrgAsync(organizationId)) + // If we're not an "admin" we don't need to check the ciphers + if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or + { Permissions.EditAnyCollection: true })) { return false; } @@ -416,8 +417,9 @@ public class CiphersController : Controller { var org = _currentContext.GetOrganization(organizationId); - // If we're not an "admin" or if we're a provider user we don't need to check the ciphers - if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or { Permissions.EditAnyCollection: true }) || await _currentContext.ProviderUserForOrgAsync(organizationId)) + // If we're not an "admin" we don't need to check the ciphers + if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or + { Permissions.EditAnyCollection: true })) { return false; } From 03118079516f02a7022d8727db3410e6ecc2bc2a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 13 Nov 2025 11:31:52 +0100 Subject: [PATCH 23/77] [deps]: Update actions/upload-artifact action to v5 (#6558) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Daniel García --- .github/workflows/build.yml | 16 ++++++++-------- .github/workflows/test-database.yml | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index baba0fb776..04434e4bad 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -159,7 +159,7 @@ jobs: ls -atlh ../../../ - name: Upload project artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 if: ${{ matrix.dotnet }} with: name: ${{ matrix.project_name }}.zip @@ -364,7 +364,7 @@ jobs: if: | github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: docker-stub-US.zip path: docker-stub-US.zip @@ -374,7 +374,7 @@ jobs: if: | github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: docker-stub-EU.zip path: docker-stub-EU.zip @@ -386,21 +386,21 @@ jobs: pwsh ./generate_openapi_files.ps1 - name: Upload Public API Swagger artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: swagger.json path: api.public.json if-no-files-found: error - name: Upload Internal API Swagger artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: internal.json path: api.json if-no-files-found: error - name: Upload Identity Swagger artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: identity.json path: identity.json @@ -446,7 +446,7 @@ jobs: - name: Upload project artifact for Windows if: ${{ contains(matrix.target, 'win') == true }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: MsSqlMigratorUtility-${{ matrix.target }} path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility.exe @@ -454,7 +454,7 @@ jobs: - name: Upload project artifact if: ${{ contains(matrix.target, 'win') == false }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: MsSqlMigratorUtility-${{ matrix.target }} path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility diff --git a/.github/workflows/test-database.yml b/.github/workflows/test-database.yml index 4a973c0b7c..fb1c18b158 100644 --- a/.github/workflows/test-database.yml +++ b/.github/workflows/test-database.yml @@ -197,7 +197,7 @@ jobs: shell: pwsh - name: Upload DACPAC - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: sql.dacpac path: Sql.dacpac @@ -223,7 +223,7 @@ jobs: shell: pwsh - name: Report validation results - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: report.xml path: | From a03994d16a70388653be8b4c39818d6f7bc75e6f Mon Sep 17 00:00:00 2001 From: Vince Grassia <593223+vgrassia@users.noreply.github.com> Date: Thu, 13 Nov 2025 07:52:26 -0500 Subject: [PATCH 24/77] Update build workflow (#6572) --- .github/workflows/build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 04434e4bad..2d92c68b93 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -46,8 +46,10 @@ jobs: permissions: security-events: write id-token: write + timeout-minutes: 45 strategy: fail-fast: false + max-parallel: 5 matrix: include: - project_name: Admin From de4955a8753c03f1a5d141e5a63b36bda8a4b348 Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Thu, 13 Nov 2025 15:06:26 +0100 Subject: [PATCH 25/77] [PM-27181] - Grant additional permissions for review code (#6576) --- .github/workflows/review-code.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/review-code.yml b/.github/workflows/review-code.yml index 46309af38e..0e0597fccf 100644 --- a/.github/workflows/review-code.yml +++ b/.github/workflows/review-code.yml @@ -15,6 +15,7 @@ jobs: AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} permissions: + actions: read contents: read id-token: write pull-requests: write From 59a64af3453cfa949194138ae6e25927c9ab897a Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Thu, 13 Nov 2025 09:09:01 -0600 Subject: [PATCH 26/77] [PM-26435] Milestone 3 / F19R (#6574) * Re-organize UpcomingInvoiceHandler for readability * Milestone 3 renewal * Map premium access data from additonal data in pricing * Feedback * Fix test --- .../Implementations/UpcomingInvoiceHandler.cs | 535 +++++++++++------- src/Core/Billing/Constants/StripeConstants.cs | 1 + .../Pricing/Organizations/PlanAdapter.cs | 12 +- .../Services/UpcomingInvoiceHandlerTests.cs | 523 +++++++++++++++++ 4 files changed, 881 insertions(+), 190 deletions(-) diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index 7a58f84cd4..1db469a4e2 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -1,11 +1,8 @@ -// FIXME: Update this file to be null safe and then delete the line below - -#nullable disable - -using Bit.Core; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Payment.Queries; @@ -17,11 +14,13 @@ using Bit.Core.Platform.Mail.Mailer; using Bit.Core.Repositories; using Bit.Core.Services; using Stripe; -using static Bit.Core.Billing.Constants.StripeConstants; using Event = Stripe.Event; +using Plan = Bit.Core.Models.StaticStore.Plan; namespace Bit.Billing.Services.Implementations; +using static StripeConstants; + public class UpcomingInvoiceHandler( IGetPaymentMethodQuery getPaymentMethodQuery, ILogger logger, @@ -57,204 +56,88 @@ public class UpcomingInvoiceHandler( if (organizationId.HasValue) { - var organization = await organizationRepository.GetByIdAsync(organizationId.Value); - - if (organization == null) - { - return; - } - - await AlignOrganizationTaxConcernsAsync(organization, subscription, customer, parsedEvent.Id); - - var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); - - if (!plan.IsAnnual) - { - return; - } - - if (stripeEventUtilityService.IsSponsoredSubscription(subscription)) - { - var sponsorshipIsValid = - await validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId.Value); - - if (!sponsorshipIsValid) - { - /* - * If the sponsorship is invalid, then the subscription was updated to use the regular families plan - * price. Given that this is the case, we need the new invoice amount - */ - invoice = await stripeFacade.GetInvoice(subscription.LatestInvoiceId); - } - } - - await SendUpcomingInvoiceEmailsAsync(new List { organization.BillingEmail }, invoice); - - /* - * TODO: https://bitwarden.atlassian.net/browse/PM-4862 - * Disabling this as part of a hot fix. It needs to check whether the organization - * belongs to a Reseller provider and only send an email to the organization owners if it does. - * It also requires a new email template as the current one contains too much billing information. - */ - - // var ownerEmails = await _organizationRepository.GetOwnerEmailAddressesById(organization.Id); - - // await SendEmails(ownerEmails); + await HandleOrganizationUpcomingInvoiceAsync( + organizationId.Value, + parsedEvent, + invoice, + customer, + subscription); } else if (userId.HasValue) { - var user = await userRepository.GetByIdAsync(userId.Value); - - if (user == null) - { - return; - } - - if (!subscription.AutomaticTax.Enabled && subscription.Customer.HasRecognizedTaxLocation()) - { - try - { - await stripeFacade.UpdateSubscription(subscription.Id, - new SubscriptionUpdateOptions - { - AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } - }); - } - catch (Exception exception) - { - logger.LogError( - exception, - "Failed to set user's ({UserID}) subscription to automatic tax while processing event with ID {EventID}", - user.Id, - parsedEvent.Id); - } - } - - var milestone2Feature = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2); - if (milestone2Feature) - { - await UpdateSubscriptionItemPriceIdAsync(parsedEvent, subscription, user); - } - - if (user.Premium) - { - await (milestone2Feature - ? SendUpdatedUpcomingInvoiceEmailsAsync(new List { user.Email }) - : SendUpcomingInvoiceEmailsAsync(new List { user.Email }, invoice)); - } + await HandlePremiumUsersUpcomingInvoiceAsync( + userId.Value, + parsedEvent, + invoice, + customer, + subscription); } else if (providerId.HasValue) { - var provider = await providerRepository.GetByIdAsync(providerId.Value); - - if (provider == null) - { - return; - } - - await AlignProviderTaxConcernsAsync(provider, subscription, customer, parsedEvent.Id); - - await SendProviderUpcomingInvoiceEmailsAsync(new List { provider.BillingEmail }, invoice, subscription, providerId.Value); + await HandleProviderUpcomingInvoiceAsync( + providerId.Value, + parsedEvent, + invoice, + customer, + subscription); } } - private async Task UpdateSubscriptionItemPriceIdAsync(Event parsedEvent, Subscription subscription, User user) + #region Organizations + + private async Task HandleOrganizationUpcomingInvoiceAsync( + Guid organizationId, + Event @event, + Invoice invoice, + Customer customer, + Subscription subscription) { - var pricingItem = - subscription.Items.FirstOrDefault(i => i.Price.Id == Prices.PremiumAnnually); - if (pricingItem != null) + var organization = await organizationRepository.GetByIdAsync(organizationId); + + if (organization == null) { - try + logger.LogWarning("Could not find Organization ({OrganizationID}) for '{EventType}' event ({EventID})", + organizationId, @event.Type, @event.Id); + return; + } + + await AlignOrganizationTaxConcernsAsync(organization, subscription, customer, @event.Id); + + var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); + + var milestone3 = featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3); + + await AlignOrganizationSubscriptionConcernsAsync( + organization, + @event, + subscription, + plan, + milestone3); + + // Don't send the upcoming invoice email unless the organization's on an annual plan. + if (!plan.IsAnnual) + { + return; + } + + if (stripeEventUtilityService.IsSponsoredSubscription(subscription)) + { + var sponsorshipIsValid = + await validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId); + + if (!sponsorshipIsValid) { - var plan = await pricingClient.GetAvailablePremiumPlan(); - await stripeFacade.UpdateSubscription(subscription.Id, - new SubscriptionUpdateOptions - { - Items = - [ - new SubscriptionItemOptions { Id = pricingItem.Id, Price = plan.Seat.StripePriceId } - ], - Discounts = - [ - new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone2SubscriptionDiscount } - ], - ProrationBehavior = "none" - }); - } - catch (Exception exception) - { - logger.LogError( - exception, - "Failed to update user's ({UserID}) subscription price id while processing event with ID {EventID}", - user.Id, - parsedEvent.Id); + /* + * If the sponsorship is invalid, then the subscription was updated to use the regular families plan + * price. Given that this is the case, we need the new invoice amount + */ + invoice = await stripeFacade.GetInvoice(subscription.LatestInvoiceId); } } - } - private async Task SendUpcomingInvoiceEmailsAsync(IEnumerable emails, Invoice invoice) - { - var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); - - var items = invoice.Lines.Select(i => i.Description).ToList(); - - if (invoice.NextPaymentAttempt.HasValue && invoice.AmountDue > 0) - { - await mailService.SendInvoiceUpcoming( - validEmails, - invoice.AmountDue / 100M, - invoice.NextPaymentAttempt.Value, - items, - true); - } - } - - private async Task SendUpdatedUpcomingInvoiceEmailsAsync(IEnumerable emails) - { - var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); - var updatedUpcomingEmail = new UpdatedInvoiceUpcomingMail - { - ToEmails = validEmails, - View = new UpdatedInvoiceUpcomingView() - }; - await mailer.SendEmail(updatedUpcomingEmail); - } - - private async Task SendProviderUpcomingInvoiceEmailsAsync(IEnumerable emails, Invoice invoice, - Subscription subscription, Guid providerId) - { - var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); - - var items = invoice.FormatForProvider(subscription); - - if (invoice.NextPaymentAttempt.HasValue && invoice.AmountDue > 0) - { - var provider = await providerRepository.GetByIdAsync(providerId); - if (provider == null) - { - logger.LogWarning("Provider {ProviderId} not found for invoice upcoming email", providerId); - return; - } - - var collectionMethod = subscription.CollectionMethod; - var paymentMethod = await getPaymentMethodQuery.Run(provider); - - var hasPaymentMethod = paymentMethod != null; - var paymentMethodDescription = paymentMethod?.Match( - bankAccount => $"Bank account ending in {bankAccount.Last4}", - card => $"{card.Brand} ending in {card.Last4}", - payPal => $"PayPal account {payPal.Email}" - ); - - await mailService.SendProviderInvoiceUpcoming( - validEmails, - invoice.AmountDue / 100M, - invoice.NextPaymentAttempt.Value, - items, - collectionMethod, - hasPaymentMethod, - paymentMethodDescription); - } + await (milestone3 + ? SendUpdatedUpcomingInvoiceEmailsAsync([organization.BillingEmail]) + : SendUpcomingInvoiceEmailsAsync([organization.BillingEmail], invoice)); } private async Task AlignOrganizationTaxConcernsAsync( @@ -305,6 +188,209 @@ public class UpcomingInvoiceHandler( } } + private async Task AlignOrganizationSubscriptionConcernsAsync( + Organization organization, + Event @event, + Subscription subscription, + Plan plan, + bool milestone3) + { + if (milestone3 && plan.Type == PlanType.FamiliesAnnually2019) + { + var passwordManagerItem = + subscription.Items.FirstOrDefault(item => item.Price.Id == plan.PasswordManager.StripePlanId); + + if (passwordManagerItem == null) + { + logger.LogWarning("Could not find Organization's ({OrganizationId}) password manager item while processing '{EventType}' event ({EventID})", + organization.Id, @event.Type, @event.Id); + return; + } + + var families = await pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually); + + organization.PlanType = families.Type; + organization.Plan = families.Name; + organization.UsersGetPremium = families.UsersGetPremium; + organization.Seats = families.PasswordManager.BaseSeats; + + var options = new SubscriptionUpdateOptions + { + Items = + [ + new SubscriptionItemOptions + { + Id = passwordManagerItem.Id, Price = families.PasswordManager.StripePlanId + } + ], + Discounts = + [ + new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone3SubscriptionDiscount } + ], + ProrationBehavior = ProrationBehavior.None + }; + + var premiumAccessAddOnItem = subscription.Items.FirstOrDefault(item => + item.Price.Id == plan.PasswordManager.StripePremiumAccessPlanId); + + if (premiumAccessAddOnItem != null) + { + options.Items.Add(new SubscriptionItemOptions + { + Id = premiumAccessAddOnItem.Id, + Deleted = true + }); + } + + try + { + await organizationRepository.ReplaceAsync(organization); + await stripeFacade.UpdateSubscription(subscription.Id, options); + } + catch (Exception exception) + { + logger.LogError( + exception, + "Failed to align subscription concerns for Organization ({OrganizationID}) while processing '{EventType}' event ({EventID})", + organization.Id, + @event.Type, + @event.Id); + } + } + } + + #endregion + + #region Premium Users + + private async Task HandlePremiumUsersUpcomingInvoiceAsync( + Guid userId, + Event @event, + Invoice invoice, + Customer customer, + Subscription subscription) + { + var user = await userRepository.GetByIdAsync(userId); + + if (user == null) + { + logger.LogWarning("Could not find User ({UserID}) for '{EventType}' event ({EventID})", + userId, @event.Type, @event.Id); + return; + } + + await AlignPremiumUsersTaxConcernsAsync(user, @event, customer, subscription); + + var milestone2Feature = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2); + if (milestone2Feature) + { + await AlignPremiumUsersSubscriptionConcernsAsync(user, @event, subscription); + } + + if (user.Premium) + { + await (milestone2Feature + ? SendUpdatedUpcomingInvoiceEmailsAsync(new List { user.Email }) + : SendUpcomingInvoiceEmailsAsync(new List { user.Email }, invoice)); + } + } + + private async Task AlignPremiumUsersTaxConcernsAsync( + User user, + Event @event, + Customer customer, + Subscription subscription) + { + if (!subscription.AutomaticTax.Enabled && customer.HasRecognizedTaxLocation()) + { + try + { + await stripeFacade.UpdateSubscription(subscription.Id, + new SubscriptionUpdateOptions + { + AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } + }); + } + catch (Exception exception) + { + logger.LogError( + exception, + "Failed to set user's ({UserID}) subscription to automatic tax while processing event with ID {EventID}", + user.Id, + @event.Id); + } + } + } + + private async Task AlignPremiumUsersSubscriptionConcernsAsync( + User user, + Event @event, + Subscription subscription) + { + var premiumItem = subscription.Items.FirstOrDefault(i => i.Price.Id == Prices.PremiumAnnually); + + if (premiumItem == null) + { + logger.LogWarning("Could not find User's ({UserID}) premium subscription item while processing '{EventType}' event ({EventID})", + user.Id, @event.Type, @event.Id); + return; + } + + try + { + var plan = await pricingClient.GetAvailablePremiumPlan(); + await stripeFacade.UpdateSubscription(subscription.Id, + new SubscriptionUpdateOptions + { + Items = + [ + new SubscriptionItemOptions { Id = premiumItem.Id, Price = plan.Seat.StripePriceId } + ], + Discounts = + [ + new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone2SubscriptionDiscount } + ], + ProrationBehavior = ProrationBehavior.None + }); + } + catch (Exception exception) + { + logger.LogError( + exception, + "Failed to update user's ({UserID}) subscription price id while processing event with ID {EventID}", + user.Id, + @event.Id); + } + } + + #endregion + + #region Providers + + private async Task HandleProviderUpcomingInvoiceAsync( + Guid providerId, + Event @event, + Invoice invoice, + Customer customer, + Subscription subscription) + { + var provider = await providerRepository.GetByIdAsync(providerId); + + if (provider == null) + { + logger.LogWarning("Could not find Provider ({ProviderID}) for '{EventType}' event ({EventID})", + providerId, @event.Type, @event.Id); + return; + } + + await AlignProviderTaxConcernsAsync(provider, subscription, customer, @event.Id); + + if (!string.IsNullOrEmpty(provider.BillingEmail)) + { + await SendProviderUpcomingInvoiceEmailsAsync(new List { provider.BillingEmail }, invoice, subscription, providerId); + } + } + private async Task AlignProviderTaxConcernsAsync( Provider provider, Subscription subscription, @@ -349,4 +435,75 @@ public class UpcomingInvoiceHandler( } } } + + private async Task SendProviderUpcomingInvoiceEmailsAsync(IEnumerable emails, Invoice invoice, + Subscription subscription, Guid providerId) + { + var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); + + var items = invoice.FormatForProvider(subscription); + + if (invoice.NextPaymentAttempt.HasValue && invoice.AmountDue > 0) + { + var provider = await providerRepository.GetByIdAsync(providerId); + if (provider == null) + { + logger.LogWarning("Provider {ProviderId} not found for invoice upcoming email", providerId); + return; + } + + var collectionMethod = subscription.CollectionMethod; + var paymentMethod = await getPaymentMethodQuery.Run(provider); + + var hasPaymentMethod = paymentMethod != null; + var paymentMethodDescription = paymentMethod?.Match( + bankAccount => $"Bank account ending in {bankAccount.Last4}", + card => $"{card.Brand} ending in {card.Last4}", + payPal => $"PayPal account {payPal.Email}" + ); + + await mailService.SendProviderInvoiceUpcoming( + validEmails, + invoice.AmountDue / 100M, + invoice.NextPaymentAttempt.Value, + items, + collectionMethod, + hasPaymentMethod, + paymentMethodDescription); + } + } + + #endregion + + #region Shared + + private async Task SendUpcomingInvoiceEmailsAsync(IEnumerable emails, Invoice invoice) + { + var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); + + var items = invoice.Lines.Select(i => i.Description).ToList(); + + if (invoice is { NextPaymentAttempt: not null, AmountDue: > 0 }) + { + await mailService.SendInvoiceUpcoming( + validEmails, + invoice.AmountDue / 100M, + invoice.NextPaymentAttempt.Value, + items, + true); + } + } + + private async Task SendUpdatedUpcomingInvoiceEmailsAsync(IEnumerable emails) + { + var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); + var updatedUpcomingEmail = new UpdatedInvoiceUpcomingMail + { + ToEmails = validEmails, + View = new UpdatedInvoiceUpcomingView() + }; + await mailer.SendEmail(updatedUpcomingEmail); + } + + #endregion } diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index 9cfb4e9b0d..11f043fc69 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -23,6 +23,7 @@ public static class StripeConstants public const string LegacyMSPDiscount = "msp-discount-35"; public const string SecretsManagerStandalone = "sm-standalone"; public const string Milestone2SubscriptionDiscount = "milestone-2c"; + public const string Milestone3SubscriptionDiscount = "milestone-3"; public static class MSPDiscounts { diff --git a/src/Core/Billing/Pricing/Organizations/PlanAdapter.cs b/src/Core/Billing/Pricing/Organizations/PlanAdapter.cs index ac60411366..37dc63cb47 100644 --- a/src/Core/Billing/Pricing/Organizations/PlanAdapter.cs +++ b/src/Core/Billing/Pricing/Organizations/PlanAdapter.cs @@ -104,6 +104,14 @@ public record PlanAdapter : Core.Models.StaticStore.Plan var additionalStoragePricePerGb = plan.Storage?.Price ?? 0; var stripeStoragePlanId = plan.Storage?.StripePriceId; short? maxCollections = plan.AdditionalData.TryGetValue("passwordManager.maxCollections", out var value) ? short.Parse(value) : null; + var stripePremiumAccessPlanId = + plan.AdditionalData.TryGetValue("premiumAccessAddOnPriceId", out var premiumAccessAddOnPriceIdValue) + ? premiumAccessAddOnPriceIdValue + : null; + var premiumAccessOptionPrice = + plan.AdditionalData.TryGetValue("premiumAccessAddOnPriceAmount", out var premiumAccessAddOnPriceAmountValue) + ? decimal.Parse(premiumAccessAddOnPriceAmountValue) + : 0; return new PasswordManagerPlanFeatures { @@ -121,7 +129,9 @@ public record PlanAdapter : Core.Models.StaticStore.Plan HasAdditionalStorageOption = hasAdditionalStorageOption, AdditionalStoragePricePerGb = additionalStoragePricePerGb, StripeStoragePlanId = stripeStoragePlanId, - MaxCollections = maxCollections + MaxCollections = maxCollections, + StripePremiumAccessPlanId = stripePremiumAccessPlanId, + PremiumAccessOptionPrice = premiumAccessOptionPrice }; } diff --git a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs index 913355f2db..01a2975e8b 100644 --- a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs +++ b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs @@ -945,4 +945,527 @@ public class UpcomingInvoiceHandlerTests Arg.Any(), Arg.Any()); } + + [Fact] + public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2019Plan_UpdatesSubscriptionAndOrganization() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; + var customerId = "cus_123"; + var subscriptionId = "sub_123"; + var passwordManagerItemId = "si_pm_123"; + var premiumAccessItemId = "si_premium_123"; + + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 40000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + + var families2019Plan = new Families2019Plan(); + var familiesPlan = new FamiliesPlan(); + + var subscription = new Subscription + { + Id = subscriptionId, + CustomerId = customerId, + Items = new StripeList + { + Data = new List + { + new() + { + Id = passwordManagerItemId, + Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } + }, + new() + { + Id = premiumAccessItemId, + Price = new Price { Id = families2019Plan.PasswordManager.StripePremiumAccessPlanId } + } + } + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Metadata = new Dictionary() + }; + + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = new List { subscription } }, + Address = new Address { Country = "US" } + }; + + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.FamiliesAnnually2019 + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); + _featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription( + Arg.Is(subscriptionId), + Arg.Is(o => + o.Items.Count == 2 && + o.Items[0].Id == passwordManagerItemId && + o.Items[0].Price == familiesPlan.PasswordManager.StripePlanId && + o.Items[1].Id == premiumAccessItemId && + o.Items[1].Deleted == true && + o.Discounts.Count == 1 && + o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount && + o.ProrationBehavior == ProrationBehavior.None)); + + await _organizationRepository.Received(1).ReplaceAsync( + Arg.Is(org => + org.Id == _organizationId && + org.PlanType == PlanType.FamiliesAnnually && + org.Plan == familiesPlan.Name && + org.UsersGetPremium == familiesPlan.UsersGetPremium && + org.Seats == familiesPlan.PasswordManager.BaseSeats)); + + await _mailer.Received(1).SendEmail( + Arg.Is(email => + email.ToEmails.Contains("org@example.com") && + email.Subject == "Your Subscription Will Renew Soon")); + } + + [Fact] + public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2019Plan_WithoutPremiumAccess_UpdatesSubscriptionAndOrganization() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; + var customerId = "cus_123"; + var subscriptionId = "sub_123"; + var passwordManagerItemId = "si_pm_123"; + + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 40000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + + var families2019Plan = new Families2019Plan(); + var familiesPlan = new FamiliesPlan(); + + var subscription = new Subscription + { + Id = subscriptionId, + CustomerId = customerId, + Items = new StripeList + { + Data = new List + { + new() + { + Id = passwordManagerItemId, + Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } + } + } + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Metadata = new Dictionary() + }; + + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = new List { subscription } }, + Address = new Address { Country = "US" } + }; + + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.FamiliesAnnually2019 + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); + _featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription( + Arg.Is(subscriptionId), + Arg.Is(o => + o.Items.Count == 1 && + o.Items[0].Id == passwordManagerItemId && + o.Items[0].Price == familiesPlan.PasswordManager.StripePlanId && + o.Discounts.Count == 1 && + o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount && + o.ProrationBehavior == ProrationBehavior.None)); + + await _organizationRepository.Received(1).ReplaceAsync( + Arg.Is(org => + org.Id == _organizationId && + org.PlanType == PlanType.FamiliesAnnually && + org.Plan == familiesPlan.Name && + org.UsersGetPremium == familiesPlan.UsersGetPremium && + org.Seats == familiesPlan.PasswordManager.BaseSeats)); + } + + [Fact] + public async Task HandleAsync_WhenMilestone3Disabled_DoesNotUpdateSubscription() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; + var customerId = "cus_123"; + var subscriptionId = "sub_123"; + var passwordManagerItemId = "si_pm_123"; + + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 40000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + + var families2019Plan = new Families2019Plan(); + + var subscription = new Subscription + { + Id = subscriptionId, + CustomerId = customerId, + Items = new StripeList + { + Data = new List + { + new() + { + Id = passwordManagerItemId, + Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } + } + } + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Metadata = new Dictionary() + }; + + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = new List { subscription } }, + Address = new Address { Country = "US" } + }; + + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.FamiliesAnnually2019 + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan); + _featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(false); + _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert - should not update subscription or organization when feature flag is disabled + await _stripeFacade.DidNotReceive().UpdateSubscription( + Arg.Any(), + Arg.Is(o => o.Discounts != null)); + + await _organizationRepository.DidNotReceive().ReplaceAsync( + Arg.Is(org => org.PlanType == PlanType.FamiliesAnnually)); + } + + [Fact] + public async Task HandleAsync_WhenMilestone3Enabled_ButNotFamilies2019Plan_DoesNotUpdateSubscription() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; + var customerId = "cus_123"; + var subscriptionId = "sub_123"; + + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 40000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + + var familiesPlan = new FamiliesPlan(); + + var subscription = new Subscription + { + Id = subscriptionId, + CustomerId = customerId, + Items = new StripeList + { + Data = new List + { + new() + { + Id = "si_pm_123", + Price = new Price { Id = familiesPlan.PasswordManager.StripePlanId } + } + } + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Metadata = new Dictionary() + }; + + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = new List { subscription } }, + Address = new Address { Country = "US" } + }; + + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.FamiliesAnnually // Already on the new plan + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); + _featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert - should not update subscription when not on FamiliesAnnually2019 plan + await _stripeFacade.DidNotReceive().UpdateSubscription( + Arg.Any(), + Arg.Is(o => o.Discounts != null)); + + await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any()); + } + + [Fact] + public async Task HandleAsync_WhenMilestone3Enabled_AndPasswordManagerItemNotFound_LogsWarning() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; + var customerId = "cus_123"; + var subscriptionId = "sub_123"; + + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 40000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + + var families2019Plan = new Families2019Plan(); + + var subscription = new Subscription + { + Id = subscriptionId, + CustomerId = customerId, + Items = new StripeList + { + Data = new List + { + new() + { + Id = "si_different_item", + Price = new Price { Id = "different-price-id" } + } + } + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Metadata = new Dictionary() + }; + + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = new List { subscription } }, + Address = new Address { Country = "US" } + }; + + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.FamiliesAnnually2019 + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan); + _featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + _logger.Received(1).Log( + LogLevel.Warning, + Arg.Any(), + Arg.Is(o => + o.ToString().Contains($"Could not find Organization's ({_organizationId}) password manager item") && + o.ToString().Contains(parsedEvent.Id)), + Arg.Any(), + Arg.Any>()); + + // Should not update subscription or organization when password manager item not found + await _stripeFacade.DidNotReceive().UpdateSubscription( + Arg.Any(), + Arg.Is(o => o.Discounts != null)); + + await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any()); + } + + [Fact] + public async Task HandleAsync_WhenMilestone3Enabled_AndUpdateFails_LogsError() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; + var customerId = "cus_123"; + var subscriptionId = "sub_123"; + var passwordManagerItemId = "si_pm_123"; + + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 40000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + + var families2019Plan = new Families2019Plan(); + var familiesPlan = new FamiliesPlan(); + + var subscription = new Subscription + { + Id = subscriptionId, + CustomerId = customerId, + Items = new StripeList + { + Data = new List + { + new() + { + Id = passwordManagerItemId, + Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } + } + } + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Metadata = new Dictionary() + }; + + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = new List { subscription } }, + Address = new Address { Country = "US" } + }; + + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.FamiliesAnnually2019 + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); + _featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); + + // Simulate update failure + _stripeFacade + .UpdateSubscription(Arg.Any(), Arg.Any()) + .ThrowsAsync(new Exception("Stripe API error")); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + _logger.Received(1).Log( + LogLevel.Error, + Arg.Any(), + Arg.Is(o => + o.ToString().Contains($"Failed to align subscription concerns for Organization ({_organizationId})") && + o.ToString().Contains(parsedEvent.Type) && + o.ToString().Contains(parsedEvent.Id)), + 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")); + } } From e7b4837be9a7c70b6404ec4e7f026375048b4c75 Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Thu, 13 Nov 2025 11:33:24 -0600 Subject: [PATCH 27/77] [PM-26377] Add Auto Confirm Policy (#6552) * First pass at adding Automatic User Confirmation Policy. * Adding edge case tests. Adding side effect of updating organization feature. Removing account recovery restriction from validation. * Added implementation for the vnext save * Added documentation to different event types with remarks. Updated IPolicyValidator xml docs. --- .../Policies/IPolicyValidator.cs | 4 + .../PolicyServiceCollectionExtensions.cs | 1 + .../IEnforceDependentPoliciesEvent.cs | 7 + .../Interfaces/IOnPolicyPreUpdateEvent.cs | 6 + .../Interfaces/IPolicyUpdateEvent.cs | 6 + .../Interfaces/IPolicyValidationEvent.cs | 11 +- ...maticUserConfirmationPolicyEventHandler.cs | 131 ++++ ...UserConfirmationPolicyEventHandlerTests.cs | 628 ++++++++++++++++++ 8 files changed, 791 insertions(+), 3 deletions(-) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyValidator.cs index 6aef9f248b..d3df63b6ac 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyValidator.cs @@ -9,6 +9,10 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies; /// /// Defines behavior and functionality for a given PolicyType. /// +/// +/// All methods defined in this interface are for the PolicyService#SavePolicy method. This needs to be supported until +/// we successfully refactor policy validators over to policy validation handlers +/// public interface IPolicyValidator { /// diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs index f3dbc83706..7c1987865a 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs @@ -53,6 +53,7 @@ public static class PolicyServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } private static void AddPolicyRequirements(this IServiceCollection services) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IEnforceDependentPoliciesEvent.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IEnforceDependentPoliciesEvent.cs index 798417ae7c..0e2bdc3d69 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IEnforceDependentPoliciesEvent.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IEnforceDependentPoliciesEvent.cs @@ -2,6 +2,13 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; +/// +/// Represents all policies required to be enabled before the given policy can be enabled. +/// +/// +/// This interface is intended for policy event handlers that mandate the activation of other policies +/// as prerequisites for enabling the associated policy. +/// public interface IEnforceDependentPoliciesEvent : IPolicyUpdateEvent { /// diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IOnPolicyPreUpdateEvent.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IOnPolicyPreUpdateEvent.cs index 278a17f35e..4167a392e4 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IOnPolicyPreUpdateEvent.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IOnPolicyPreUpdateEvent.cs @@ -3,6 +3,12 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; +/// +/// Represents all side effects that should be executed before a policy is upserted. +/// +/// +/// This should be added to policy handlers that need to perform side effects before policy upserts. +/// public interface IOnPolicyPreUpdateEvent : IPolicyUpdateEvent { /// diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyUpdateEvent.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyUpdateEvent.cs index ded1a14f1a..a568658d4d 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyUpdateEvent.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyUpdateEvent.cs @@ -2,6 +2,12 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; +/// +/// Represents the policy to be upserted. +/// +/// +/// This is used for the VNextSavePolicyCommand. All policy handlers should implement this interface. +/// public interface IPolicyUpdateEvent { /// diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyValidationEvent.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyValidationEvent.cs index 6d486e1fa0..ee401ef813 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyValidationEvent.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyValidationEvent.cs @@ -3,12 +3,17 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; +/// +/// Represents all validations that need to be run to enable or disable the given policy. +/// +/// +/// This is used for the VNextSavePolicyCommand. This optional but should be implemented for all policies that have +/// certain requirements for the given organization. +/// public interface IPolicyValidationEvent : IPolicyUpdateEvent { /// - /// Performs side effects after a policy is validated but before it is saved. - /// For example, this can be used to remove non-compliant users from the organization. - /// Implementation is optional; by default, it will not perform any side effects. + /// Performs any validations required to enable or disable the policy. /// /// The policy save request containing the policy update and metadata /// The current policy, if any diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs new file mode 100644 index 0000000000..c0d302df02 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs @@ -0,0 +1,131 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Enums; +using Bit.Core.Repositories; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +/// +/// Represents an event handler for the Automatic User Confirmation policy. +/// +/// This class validates that the following conditions are met: +///
    +///
  • The Single organization policy is enabled
  • +///
  • All organization users are compliant with the Single organization policy
  • +///
  • No provider users exist
  • +///
+/// +/// This class also performs side effects when the policy is being enabled or disabled. They are: +///
    +///
  • Sets the UseAutomaticUserConfirmation organization feature to match the policy update
  • +///
+///
+public class AutomaticUserConfirmationPolicyEventHandler( + IOrganizationUserRepository organizationUserRepository, + IProviderUserRepository providerUserRepository, + IPolicyRepository policyRepository, + IOrganizationRepository organizationRepository, + TimeProvider timeProvider) + : IPolicyValidator, IPolicyValidationEvent, IOnPolicyPreUpdateEvent, IEnforceDependentPoliciesEvent +{ + public PolicyType Type => PolicyType.AutomaticUserConfirmation; + public async Task ExecutePreUpsertSideEffectAsync(SavePolicyModel policyRequest, Policy? currentPolicy) => + await OnSaveSideEffectsAsync(policyRequest.PolicyUpdate, currentPolicy); + + private const string _singleOrgPolicyNotEnabledErrorMessage = + "The Single organization policy must be enabled before enabling the Automatically confirm invited users policy."; + + private const string _usersNotCompliantWithSingleOrgErrorMessage = + "All organization users must be compliant with the Single organization policy before enabling the Automatically confirm invited users policy. Please remove users who are members of multiple organizations."; + + private const string _providerUsersExistErrorMessage = + "The organization has users with the Provider user type. Please remove provider users before enabling the Automatically confirm invited users policy."; + + public IEnumerable RequiredPolicies => [PolicyType.SingleOrg]; + + public async Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + { + var isNotEnablingPolicy = policyUpdate is not { Enabled: true }; + var policyAlreadyEnabled = currentPolicy is { Enabled: true }; + if (isNotEnablingPolicy || policyAlreadyEnabled) + { + return string.Empty; + } + + return await ValidateEnablingPolicyAsync(policyUpdate.OrganizationId); + } + + public async Task ValidateAsync(SavePolicyModel savePolicyModel, Policy? currentPolicy) => + await ValidateAsync(savePolicyModel.PolicyUpdate, currentPolicy); + + public async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + { + var organization = await organizationRepository.GetByIdAsync(policyUpdate.OrganizationId); + + if (organization is not null) + { + organization.UseAutomaticUserConfirmation = policyUpdate.Enabled; + organization.RevisionDate = timeProvider.GetUtcNow().UtcDateTime; + await organizationRepository.UpsertAsync(organization); + } + } + + private async Task ValidateEnablingPolicyAsync(Guid organizationId) + { + var singleOrgValidationError = await ValidateSingleOrgPolicyComplianceAsync(organizationId); + if (!string.IsNullOrWhiteSpace(singleOrgValidationError)) + { + return singleOrgValidationError; + } + + var providerValidationError = await ValidateNoProviderUsersAsync(organizationId); + if (!string.IsNullOrWhiteSpace(providerValidationError)) + { + return providerValidationError; + } + + return string.Empty; + } + + private async Task ValidateSingleOrgPolicyComplianceAsync(Guid organizationId) + { + var singleOrgPolicy = await policyRepository.GetByOrganizationIdTypeAsync(organizationId, PolicyType.SingleOrg); + if (singleOrgPolicy is not { Enabled: true }) + { + return _singleOrgPolicyNotEnabledErrorMessage; + } + + return await ValidateUserComplianceWithSingleOrgAsync(organizationId); + } + + private async Task ValidateUserComplianceWithSingleOrgAsync(Guid organizationId) + { + var organizationUsers = (await organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId)) + .Where(ou => ou.Status != OrganizationUserStatusType.Invited && + ou.Status != OrganizationUserStatusType.Revoked && + ou.UserId.HasValue) + .ToList(); + + if (organizationUsers.Count == 0) + { + return string.Empty; + } + + var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync( + organizationUsers.Select(ou => ou.UserId!.Value))) + .Any(uo => uo.OrganizationId != organizationId && + uo.Status != OrganizationUserStatusType.Invited); + + return hasNonCompliantUser ? _usersNotCompliantWithSingleOrgErrorMessage : string.Empty; + } + + private async Task ValidateNoProviderUsersAsync(Guid organizationId) + { + var providerUsers = await providerUserRepository.GetManyByOrganizationAsync(organizationId); + + return providerUsers.Count > 0 ? _providerUsersExistErrorMessage : string.Empty; + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs new file mode 100644 index 0000000000..4781127a3d --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs @@ -0,0 +1,628 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Repositories; +using Bit.Core.Test.AdminConsole.AutoFixture; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +[SutProviderCustomize] +public class AutomaticUserConfirmationPolicyEventHandlerTests +{ + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_SingleOrgNotEnabled_ReturnsError( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) + .Returns((Policy?)null); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.Contains("Single organization policy must be enabled", result, StringComparison.OrdinalIgnoreCase); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_SingleOrgPolicyDisabled_ReturnsError( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg, false)] Policy singleOrgPolicy, + SutProvider sutProvider) + { + // Arrange + singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) + .Returns(singleOrgPolicy); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.Contains("Single organization policy must be enabled", result, StringComparison.OrdinalIgnoreCase); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_UsersNotCompliantWithSingleOrg_ReturnsError( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, + Guid nonCompliantUserId, + SutProvider sutProvider) + { + // Arrange + singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; + + var orgUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = policyUpdate.OrganizationId, + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Confirmed, + UserId = nonCompliantUserId, + Email = "user@example.com" + }; + + var otherOrgUser = new OrganizationUser + { + Id = Guid.NewGuid(), + OrganizationId = Guid.NewGuid(), + UserId = nonCompliantUserId, + Status = OrganizationUserStatusType.Confirmed + }; + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) + .Returns(singleOrgPolicy); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([orgUser]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([otherOrgUser]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.Contains("compliant with the Single organization policy", result, StringComparison.OrdinalIgnoreCase); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_UserWithInvitedStatusInOtherOrg_ValidationPasses( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, + Guid userId, + SutProvider sutProvider) + { + // Arrange + singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; + + var orgUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = policyUpdate.OrganizationId, + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Confirmed, + UserId = userId, + Email = "test@email.com" + }; + + var otherOrgUser = new OrganizationUser + { + Id = Guid.NewGuid(), + OrganizationId = Guid.NewGuid(), + UserId = null, // invited users do not have a user id + Status = OrganizationUserStatusType.Invited, + Email = orgUser.Email + }; + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) + .Returns(singleOrgPolicy); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([orgUser]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([otherOrgUser]); + + sutProvider.GetDependency() + .GetManyByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.True(string.IsNullOrEmpty(result)); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_ProviderUsersExist_ReturnsError( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, + SutProvider sutProvider) + { + // Arrange + singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; + + var providerUser = new ProviderUser + { + Id = Guid.NewGuid(), + ProviderId = Guid.NewGuid(), + UserId = Guid.NewGuid(), + Status = ProviderUserStatusType.Confirmed + }; + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) + .Returns(singleOrgPolicy); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([]); + + sutProvider.GetDependency() + .GetManyByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([providerUser]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.Contains("Provider user type", result, StringComparison.OrdinalIgnoreCase); + } + + + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_AllValidationsPassed_ReturnsEmptyString( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, + SutProvider sutProvider) + { + // Arrange + singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; + + var orgUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = policyUpdate.OrganizationId, + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Confirmed, + UserId = Guid.NewGuid(), + Email = "user@example.com" + }; + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) + .Returns(singleOrgPolicy); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([orgUser]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([]); + + sutProvider.GetDependency() + .GetManyByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.True(string.IsNullOrEmpty(result)); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_PolicyAlreadyEnabled_ReturnsEmptyString( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.AutomaticUserConfirmation)] Policy currentPolicy, + SutProvider sutProvider) + { + // Arrange + currentPolicy.OrganizationId = policyUpdate.OrganizationId; + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, currentPolicy); + + // Assert + Assert.True(string.IsNullOrEmpty(result)); + await sutProvider.GetDependency() + .DidNotReceive() + .GetByOrganizationIdTypeAsync(Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_DisablingPolicy_ReturnsEmptyString( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation, false)] PolicyUpdate policyUpdate, + [Policy(PolicyType.AutomaticUserConfirmation)] Policy currentPolicy, + SutProvider sutProvider) + { + // Arrange + currentPolicy.OrganizationId = policyUpdate.OrganizationId; + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, currentPolicy); + + // Assert + Assert.True(string.IsNullOrEmpty(result)); + await sutProvider.GetDependency() + .DidNotReceive() + .GetByOrganizationIdTypeAsync(Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_IncludesOwnersAndAdmins_InComplianceCheck( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, + Guid nonCompliantOwnerId, + SutProvider sutProvider) + { + // Arrange + singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; + + var ownerUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = policyUpdate.OrganizationId, + Type = OrganizationUserType.Owner, + Status = OrganizationUserStatusType.Confirmed, + UserId = nonCompliantOwnerId, + Email = "owner@example.com" + }; + + var otherOrgUser = new OrganizationUser + { + Id = Guid.NewGuid(), + OrganizationId = Guid.NewGuid(), + UserId = nonCompliantOwnerId, + Status = OrganizationUserStatusType.Confirmed + }; + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) + .Returns(singleOrgPolicy); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([ownerUser]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([otherOrgUser]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.Contains("compliant with the Single organization policy", result, StringComparison.OrdinalIgnoreCase); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_InvitedUsersExcluded_FromComplianceCheck( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, + SutProvider sutProvider) + { + // Arrange + singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; + + var invitedUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = policyUpdate.OrganizationId, + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Invited, + UserId = Guid.NewGuid(), + Email = "invited@example.com" + }; + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) + .Returns(singleOrgPolicy); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([invitedUser]); + + sutProvider.GetDependency() + .GetManyByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.True(string.IsNullOrEmpty(result)); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_RevokedUsersExcluded_FromComplianceCheck( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, + SutProvider sutProvider) + { + // Arrange + singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; + + var revokedUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = policyUpdate.OrganizationId, + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Revoked, + UserId = Guid.NewGuid(), + Email = "revoked@example.com" + }; + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) + .Returns(singleOrgPolicy); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([revokedUser]); + + sutProvider.GetDependency() + .GetManyByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.True(string.IsNullOrEmpty(result)); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_AcceptedUsersIncluded_InComplianceCheck( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, + Guid nonCompliantUserId, + SutProvider sutProvider) + { + // Arrange + singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; + + var acceptedUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = policyUpdate.OrganizationId, + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Accepted, + UserId = nonCompliantUserId, + Email = "accepted@example.com" + }; + + var otherOrgUser = new OrganizationUser + { + Id = Guid.NewGuid(), + OrganizationId = Guid.NewGuid(), + UserId = nonCompliantUserId, + Status = OrganizationUserStatusType.Confirmed + }; + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) + .Returns(singleOrgPolicy); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([acceptedUser]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([otherOrgUser]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.Contains("compliant with the Single organization policy", result, StringComparison.OrdinalIgnoreCase); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_EmptyOrganization_ReturnsEmptyString( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, + SutProvider sutProvider) + { + // Arrange + singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) + .Returns(singleOrgPolicy); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([]); + + sutProvider.GetDependency() + .GetManyByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.True(string.IsNullOrEmpty(result)); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_WithSavePolicyModel_CallsValidateWithPolicyUpdate( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, + SutProvider sutProvider) + { + // Arrange + singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; + + var savePolicyModel = new SavePolicyModel(policyUpdate); + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) + .Returns(singleOrgPolicy); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([]); + + sutProvider.GetDependency() + .GetManyByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, null); + + // Assert + Assert.True(string.IsNullOrEmpty(result)); + } + + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_EnablingPolicy_SetsUseAutomaticUserConfirmationToTrue( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + Organization organization, + SutProvider sutProvider) + { + // Arrange + organization.Id = policyUpdate.OrganizationId; + organization.UseAutomaticUserConfirmation = false; + + sutProvider.GetDependency() + .GetByIdAsync(policyUpdate.OrganizationId) + .Returns(organization); + + // Act + await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpsertAsync(Arg.Is(o => + o.Id == organization.Id && + o.UseAutomaticUserConfirmation == true && + o.RevisionDate > DateTime.MinValue)); + } + + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_DisablingPolicy_SetsUseAutomaticUserConfirmationToFalse( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation, false)] PolicyUpdate policyUpdate, + Organization organization, + SutProvider sutProvider) + { + // Arrange + organization.Id = policyUpdate.OrganizationId; + organization.UseAutomaticUserConfirmation = true; + + sutProvider.GetDependency() + .GetByIdAsync(policyUpdate.OrganizationId) + .Returns(organization); + + // Act + await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpsertAsync(Arg.Is(o => + o.Id == organization.Id && + o.UseAutomaticUserConfirmation == false && + o.RevisionDate > DateTime.MinValue)); + } + + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_OrganizationNotFound_DoesNotThrowException( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetByIdAsync(policyUpdate.OrganizationId) + .Returns((Organization?)null); + + // Act + await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null); + + // Assert + await sutProvider.GetDependency() + .DidNotReceive() + .UpsertAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ExecutePreUpsertSideEffectAsync_CallsOnSaveSideEffectsAsync( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.AutomaticUserConfirmation)] Policy currentPolicy, + Organization organization, + SutProvider sutProvider) + { + // Arrange + organization.Id = policyUpdate.OrganizationId; + currentPolicy.OrganizationId = policyUpdate.OrganizationId; + + var savePolicyModel = new SavePolicyModel(policyUpdate); + + sutProvider.GetDependency() + .GetByIdAsync(policyUpdate.OrganizationId) + .Returns(organization); + + // Act + await sutProvider.Sut.ExecutePreUpsertSideEffectAsync(savePolicyModel, currentPolicy); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpsertAsync(Arg.Is(o => + o.Id == organization.Id && + o.UseAutomaticUserConfirmation == policyUpdate.Enabled)); + } + + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_UpdatesRevisionDate( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + Organization organization, + SutProvider sutProvider) + { + // Arrange + organization.Id = policyUpdate.OrganizationId; + var originalRevisionDate = DateTime.UtcNow.AddDays(-1); + organization.RevisionDate = originalRevisionDate; + + sutProvider.GetDependency() + .GetByIdAsync(policyUpdate.OrganizationId) + .Returns(organization); + + // Act + await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpsertAsync(Arg.Is(o => + o.Id == organization.Id && + o.RevisionDate > originalRevisionDate)); + } +} From 30ff175f8ed05906b746b639c7d0cd771ad48a0d Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Thu, 13 Nov 2025 12:47:06 -0500 Subject: [PATCH 28/77] Milestone 2 Handler Update (#6564) * fix(billing): update discount id * test(billing) update test --- test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs index 01a2975e8b..5ac77eb42a 100644 --- a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs +++ b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs @@ -269,6 +269,7 @@ public class UpcomingInvoiceHandlerTests Arg.Is(o => o.Items[0].Id == priceSubscriptionId && o.Items[0].Price == priceId && + o.Discounts[0].Coupon == CouponIDs.Milestone2SubscriptionDiscount && o.ProrationBehavior == "none")); // Verify the updated invoice email was sent From 9b3adf0ddc653b074274a7a0d602a8b9c21bd369 Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Fri, 14 Nov 2025 07:46:33 -0500 Subject: [PATCH 29/77] [PM-21741] Welcome email updates (#6479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(PM-21741): implement MJML welcome email templates with feature flag support - Add MJML templates for individual, family, and organization welcome emails - Track *.hbs artifacts from MJML build - Implement feature flag for gradual rollout of new email templates - Update RegisterUserCommand and HandlebarsMailService to support new templates - Add text versions and sanitization for all welcome emails - Fetch organization data from database for welcome emails - Add comprehensive test coverage for registration flow Co-authored-by: Rui Tomé <108268980+r-tome@users.noreply.github.com> --- .../src/Sso/Controllers/AccountController.cs | 18 +- .../Controllers/AccountControllerTest.cs | 129 +++ .../Tokenables/OrgUserInviteTokenable.cs | 7 +- .../Registration/IRegisterUserCommand.cs | 12 +- .../Implementations/RegisterUserCommand.cs | 154 ++- src/Core/Constants.cs | 1 + .../Auth/SendAccessEmailOtpEmailv2.html.hbs | 405 ++++---- .../Onboarding/welcome-family-user.html.hbs | 915 ++++++++++++++++++ .../Onboarding/welcome-family-user.text.hbs | 19 + .../welcome-individual-user.html.hbs | 914 +++++++++++++++++ .../welcome-individual-user.text.hbs | 18 + .../Auth/Onboarding/welcome-org-user.html.hbs | 915 ++++++++++++++++++ .../Auth/Onboarding/welcome-org-user.text.hbs | 20 + ...user.mjml => welcome-individual-user.mjml} | 0 .../Mjml/emails/Auth/send-email-otp.mjml | 21 +- .../Auth/OrganizationWelcomeEmailViewModel.cs | 6 + .../Platform/Mail/HandlebarsMailService.cs | 46 + src/Core/Platform/Mail/IMailService.cs | 21 + src/Core/Platform/Mail/NoopMailService.cs | 14 + .../Registration/RegisterUserCommandTests.cs | 296 ++++++ .../Services/HandlebarsMailServiceTests.cs | 111 +++ 21 files changed, 3794 insertions(+), 248 deletions(-) create mode 100644 src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.text.hbs create mode 100644 src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-individual-user.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-individual-user.text.hbs create mode 100644 src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-org-user.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-org-user.text.hbs rename src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/{welcome-free-user.mjml => welcome-individual-user.mjml} (100%) create mode 100644 src/Core/Models/Mail/Auth/OrganizationWelcomeEmailViewModel.cs diff --git a/bitwarden_license/src/Sso/Controllers/AccountController.cs b/bitwarden_license/src/Sso/Controllers/AccountController.cs index a0842daa34..bc26fb270a 100644 --- a/bitwarden_license/src/Sso/Controllers/AccountController.cs +++ b/bitwarden_license/src/Sso/Controllers/AccountController.cs @@ -651,7 +651,23 @@ public class AccountController : Controller EmailVerified = emailVerified, ApiKey = CoreHelpers.SecureRandomString(30) }; - await _registerUserCommand.RegisterUser(newUser); + + /* + The feature flag is checked here so that we can send the new MJML welcome email templates. + The other organization invites flows have an OrganizationUser allowing the RegisterUserCommand the ability + to fetch the Organization. The old method RegisterUser(User) here does not have that context, so we need + to use a new method RegisterSSOAutoProvisionedUserAsync(User, Organization) to send the correct email. + [PM-28057]: Prefer RegisterSSOAutoProvisionedUserAsync for SSO auto-provisioned users. + TODO: Remove Feature flag: PM-28221 + */ + if (_featureService.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)) + { + await _registerUserCommand.RegisterSSOAutoProvisionedUserAsync(newUser, organization); + } + else + { + await _registerUserCommand.RegisterUser(newUser); + } // If the organization has 2fa policy enabled, make sure to default jit user 2fa to email var twoFactorPolicy = diff --git a/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs b/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs index 0fe37d89fd..c04948e21f 100644 --- a/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs +++ b/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs @@ -5,6 +5,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; +using Bit.Core.Auth.UserFeatures.Registration; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Repositories; @@ -18,6 +19,7 @@ using Duende.IdentityServer.Models; using Duende.IdentityServer.Services; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using NSubstitute; @@ -1008,4 +1010,131 @@ public class AccountControllerTest _output.WriteLine($"Scenario={scenario} | OFF: SSO={offCounts.UserGetBySso}, Email={offCounts.UserGetByEmail}, Org={offCounts.OrgGetById}, OrgUserByOrg={offCounts.OrgUserGetByOrg}, OrgUserByEmail={offCounts.OrgUserGetByEmail}"); } } + + [Theory, BitAutoData] + public async Task AutoProvisionUserAsync_WithFeatureFlagEnabled_CallsRegisterSSOAutoProvisionedUser( + SutProvider sutProvider) + { + // Arrange + var orgId = Guid.NewGuid(); + var providerUserId = "ext-new-user"; + var email = "newuser@example.com"; + var organization = new Organization { Id = orgId, Name = "Test Org", Seats = null }; + + // No existing user (JIT provisioning scenario) + sutProvider.GetDependency().GetByEmailAsync(email).Returns((User?)null); + sutProvider.GetDependency().GetByIdAsync(orgId).Returns(organization); + sutProvider.GetDependency().GetByOrganizationEmailAsync(orgId, email) + .Returns((OrganizationUser?)null); + + // Feature flag enabled + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates) + .Returns(true); + + // Mock the RegisterSSOAutoProvisionedUserAsync to return success + sutProvider.GetDependency() + .RegisterSSOAutoProvisionedUserAsync(Arg.Any(), Arg.Any()) + .Returns(IdentityResult.Success); + + var claims = new[] + { + new Claim(JwtClaimTypes.Email, email), + new Claim(JwtClaimTypes.Name, "New User") + } as IEnumerable; + var config = new SsoConfigurationData(); + + var method = typeof(AccountController).GetMethod( + "CreateUserAndOrgUserConditionallyAsync", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + + // Act + var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method!.Invoke( + sutProvider.Sut, + new object[] + { + orgId.ToString(), + providerUserId, + claims, + null!, + config + })!; + + var result = await task; + + // Assert + await sutProvider.GetDependency().Received(1) + .RegisterSSOAutoProvisionedUserAsync( + Arg.Is(u => u.Email == email && u.Name == "New User"), + Arg.Is(o => o.Id == orgId && o.Name == "Test Org")); + + Assert.NotNull(result.user); + Assert.Equal(email, result.user.Email); + Assert.Equal(organization.Id, result.organization.Id); + } + + [Theory, BitAutoData] + public async Task AutoProvisionUserAsync_WithFeatureFlagDisabled_CallsRegisterUserInstead( + SutProvider sutProvider) + { + // Arrange + var orgId = Guid.NewGuid(); + var providerUserId = "ext-legacy-user"; + var email = "legacyuser@example.com"; + var organization = new Organization { Id = orgId, Name = "Test Org", Seats = null }; + + // No existing user (JIT provisioning scenario) + sutProvider.GetDependency().GetByEmailAsync(email).Returns((User?)null); + sutProvider.GetDependency().GetByIdAsync(orgId).Returns(organization); + sutProvider.GetDependency().GetByOrganizationEmailAsync(orgId, email) + .Returns((OrganizationUser?)null); + + // Feature flag disabled + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates) + .Returns(false); + + // Mock the RegisterUser to return success + sutProvider.GetDependency() + .RegisterUser(Arg.Any()) + .Returns(IdentityResult.Success); + + var claims = new[] + { + new Claim(JwtClaimTypes.Email, email), + new Claim(JwtClaimTypes.Name, "Legacy User") + } as IEnumerable; + var config = new SsoConfigurationData(); + + var method = typeof(AccountController).GetMethod( + "CreateUserAndOrgUserConditionallyAsync", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + + // Act + var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method!.Invoke( + sutProvider.Sut, + new object[] + { + orgId.ToString(), + providerUserId, + claims, + null!, + config + })!; + + var result = await task; + + // Assert + await sutProvider.GetDependency().Received(1) + .RegisterUser(Arg.Is(u => u.Email == email && u.Name == "Legacy User")); + + // Verify the new method was NOT called + await sutProvider.GetDependency().DidNotReceive() + .RegisterSSOAutoProvisionedUserAsync(Arg.Any(), Arg.Any()); + + Assert.NotNull(result.user); + Assert.Equal(email, result.user.Email); + } } diff --git a/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs index f04a1181c4..5be7ed481f 100644 --- a/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs +++ b/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs @@ -1,7 +1,4 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; using Bit.Core.Entities; using Bit.Core.Tokens; @@ -26,7 +23,7 @@ public class OrgUserInviteTokenable : ExpiringTokenable public string Identifier { get; set; } = TokenIdentifier; public Guid OrgUserId { get; set; } - public string OrgUserEmail { get; set; } + public string? OrgUserEmail { get; set; } [JsonConstructor] public OrgUserInviteTokenable() diff --git a/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs b/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs index 62dd9dd293..97c2eabd3c 100644 --- a/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs @@ -1,4 +1,5 @@ -using Bit.Core.Entities; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; using Microsoft.AspNetCore.Identity; namespace Bit.Core.Auth.UserFeatures.Registration; @@ -14,6 +15,15 @@ public interface IRegisterUserCommand /// public Task RegisterUser(User user); + /// + /// Creates a new user, sends a welcome email, and raises the signup reference event. + /// This method is used by SSO auto-provisioned organization Users. + /// + /// The to create + /// The associated with the user + /// + Task RegisterSSOAutoProvisionedUserAsync(User user, Organization organization); + /// /// Creates a new user with a given master password hash, sends a welcome email (differs based on initiation path), /// and raises the signup reference event. Optionally accepts an org invite token and org user id to associate diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs index 991be2b764..4aaa9360a0 100644 --- a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs @@ -1,11 +1,10 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; @@ -24,6 +23,7 @@ public class RegisterUserCommand : IRegisterUserCommand { private readonly IGlobalSettings _globalSettings; private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IOrganizationRepository _organizationRepository; private readonly IPolicyRepository _policyRepository; private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; @@ -37,24 +37,27 @@ public class RegisterUserCommand : IRegisterUserCommand private readonly IValidateRedemptionTokenCommand _validateRedemptionTokenCommand; private readonly IDataProtectorTokenFactory _emergencyAccessInviteTokenDataFactory; + private readonly IFeatureService _featureService; private readonly string _disabledUserRegistrationExceptionMsg = "Open registration has been disabled by the system administrator."; public RegisterUserCommand( - IGlobalSettings globalSettings, - IOrganizationUserRepository organizationUserRepository, - IPolicyRepository policyRepository, - IDataProtectionProvider dataProtectionProvider, - IDataProtectorTokenFactory orgUserInviteTokenDataFactory, - IDataProtectorTokenFactory registrationEmailVerificationTokenDataFactory, - IUserService userService, - IMailService mailService, - IValidateRedemptionTokenCommand validateRedemptionTokenCommand, - IDataProtectorTokenFactory emergencyAccessInviteTokenDataFactory - ) + IGlobalSettings globalSettings, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository, + IDataProtectionProvider dataProtectionProvider, + IDataProtectorTokenFactory orgUserInviteTokenDataFactory, + IDataProtectorTokenFactory registrationEmailVerificationTokenDataFactory, + IUserService userService, + IMailService mailService, + IValidateRedemptionTokenCommand validateRedemptionTokenCommand, + IDataProtectorTokenFactory emergencyAccessInviteTokenDataFactory, + IFeatureService featureService) { _globalSettings = globalSettings; _organizationUserRepository = organizationUserRepository; + _organizationRepository = organizationRepository; _policyRepository = policyRepository; _organizationServiceDataProtector = dataProtectionProvider.CreateProtector( @@ -69,9 +72,9 @@ public class RegisterUserCommand : IRegisterUserCommand _emergencyAccessInviteTokenDataFactory = emergencyAccessInviteTokenDataFactory; _providerServiceDataProtector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector"); + _featureService = featureService; } - public async Task RegisterUser(User user) { var result = await _userService.CreateUserAsync(user); @@ -83,11 +86,22 @@ public class RegisterUserCommand : IRegisterUserCommand return result; } + public async Task RegisterSSOAutoProvisionedUserAsync(User user, Organization organization) + { + var result = await _userService.CreateUserAsync(user); + if (result == IdentityResult.Success) + { + await SendWelcomeEmailAsync(user, organization); + } + + return result; + } + public async Task RegisterUserViaOrganizationInviteToken(User user, string masterPasswordHash, string orgInviteToken, Guid? orgUserId) { - ValidateOrgInviteToken(orgInviteToken, orgUserId, user); - await SetUserEmail2FaIfOrgPolicyEnabledAsync(orgUserId, user); + TryValidateOrgInviteToken(orgInviteToken, orgUserId, user); + var orgUser = await SetUserEmail2FaIfOrgPolicyEnabledAsync(orgUserId, user); user.ApiKey = CoreHelpers.SecureRandomString(30); @@ -97,16 +111,17 @@ public class RegisterUserCommand : IRegisterUserCommand } var result = await _userService.CreateUserAsync(user, masterPasswordHash); + var organization = await GetOrganizationUserOrganization(orgUserId ?? Guid.Empty, orgUser); if (result == IdentityResult.Success) { var sentWelcomeEmail = false; if (!string.IsNullOrEmpty(user.ReferenceData)) { - var referenceData = JsonConvert.DeserializeObject>(user.ReferenceData); + var referenceData = JsonConvert.DeserializeObject>(user.ReferenceData) ?? []; if (referenceData.TryGetValue("initiationPath", out var value)) { - var initiationPath = value.ToString(); - await SendAppropriateWelcomeEmailAsync(user, initiationPath); + var initiationPath = value.ToString() ?? string.Empty; + await SendAppropriateWelcomeEmailAsync(user, initiationPath, organization); sentWelcomeEmail = true; if (!string.IsNullOrEmpty(initiationPath)) { @@ -117,14 +132,22 @@ public class RegisterUserCommand : IRegisterUserCommand if (!sentWelcomeEmail) { - await _mailService.SendWelcomeEmailAsync(user); + await SendWelcomeEmailAsync(user, organization); } } return result; } - private void ValidateOrgInviteToken(string orgInviteToken, Guid? orgUserId, User user) + /// + /// This method attempts to validate the org invite token if provided. If the token is invalid an exception is thrown. + /// If there is no exception it is assumed the token is valid or not provided and open registration is allowed. + /// + /// The organization invite token. + /// The organization user ID. + /// The user being registered. + /// If validation fails then an exception is thrown. + private void TryValidateOrgInviteToken(string orgInviteToken, Guid? orgUserId, User user) { var orgInviteTokenProvided = !string.IsNullOrWhiteSpace(orgInviteToken); @@ -137,7 +160,6 @@ public class RegisterUserCommand : IRegisterUserCommand } // Token data is invalid - if (_globalSettings.DisableUserRegistration) { throw new BadRequestException(_disabledUserRegistrationExceptionMsg); @@ -147,7 +169,6 @@ public class RegisterUserCommand : IRegisterUserCommand } // no token data or missing token data - // Throw if open registration is disabled and there isn't an org invite token or an org user id // as you can't register without them. if (_globalSettings.DisableUserRegistration) @@ -171,12 +192,20 @@ public class RegisterUserCommand : IRegisterUserCommand // If both orgInviteToken && orgUserId are missing, then proceed with open registration } + /// + /// Validates the org invite token using the new tokenable logic first, then falls back to the old token validation logic for backwards compatibility. + /// Will set the out parameter organizationWelcomeEmailDetails if the new token is valid. If the token is invalid then no welcome email needs to be sent + /// so the out parameter is set to null. + /// + /// Invite token + /// Inviting Organization UserId + /// User email + /// true if the token is valid false otherwise private bool IsOrgInviteTokenValid(string orgInviteToken, Guid orgUserId, string userEmail) { // TODO: PM-4142 - remove old token validation logic once 3 releases of backwards compatibility are complete var newOrgInviteTokenValid = OrgUserInviteTokenable.ValidateOrgUserInviteStringToken( _orgUserInviteTokenDataFactory, orgInviteToken, orgUserId, userEmail); - return newOrgInviteTokenValid || CoreHelpers.UserInviteTokenIsValid( _organizationServiceDataProtector, orgInviteToken, userEmail, orgUserId, _globalSettings); } @@ -187,11 +216,12 @@ public class RegisterUserCommand : IRegisterUserCommand /// /// The optional org user id /// The newly created user object which could be modified - private async Task SetUserEmail2FaIfOrgPolicyEnabledAsync(Guid? orgUserId, User user) + /// The organization user if one exists for the provided org user id, null otherwise + private async Task SetUserEmail2FaIfOrgPolicyEnabledAsync(Guid? orgUserId, User user) { if (!orgUserId.HasValue) { - return; + return null; } var orgUser = await _organizationUserRepository.GetByIdAsync(orgUserId.Value); @@ -213,10 +243,11 @@ public class RegisterUserCommand : IRegisterUserCommand _userService.SetTwoFactorProvider(user, TwoFactorProviderType.Email); } } + return orgUser; } - private async Task SendAppropriateWelcomeEmailAsync(User user, string initiationPath) + private async Task SendAppropriateWelcomeEmailAsync(User user, string initiationPath, Organization? organization) { var isFromMarketingWebsite = initiationPath.Contains("Secrets Manager trial"); @@ -226,16 +257,14 @@ public class RegisterUserCommand : IRegisterUserCommand } else { - await _mailService.SendWelcomeEmailAsync(user); + await SendWelcomeEmailAsync(user, organization); } } public async Task RegisterUserViaEmailVerificationToken(User user, string masterPasswordHash, string emailVerificationToken) { - ValidateOpenRegistrationAllowed(); - var tokenable = ValidateRegistrationEmailVerificationTokenable(emailVerificationToken, user.Email); user.EmailVerified = true; @@ -245,7 +274,7 @@ public class RegisterUserCommand : IRegisterUserCommand var result = await _userService.CreateUserAsync(user, masterPasswordHash); if (result == IdentityResult.Success) { - await _mailService.SendWelcomeEmailAsync(user); + await SendWelcomeEmailAsync(user); } return result; @@ -263,7 +292,7 @@ public class RegisterUserCommand : IRegisterUserCommand var result = await _userService.CreateUserAsync(user, masterPasswordHash); if (result == IdentityResult.Success) { - await _mailService.SendWelcomeEmailAsync(user); + await SendWelcomeEmailAsync(user); } return result; @@ -283,7 +312,7 @@ public class RegisterUserCommand : IRegisterUserCommand var result = await _userService.CreateUserAsync(user, masterPasswordHash); if (result == IdentityResult.Success) { - await _mailService.SendWelcomeEmailAsync(user); + await SendWelcomeEmailAsync(user); } return result; @@ -301,7 +330,7 @@ public class RegisterUserCommand : IRegisterUserCommand var result = await _userService.CreateUserAsync(user, masterPasswordHash); if (result == IdentityResult.Success) { - await _mailService.SendWelcomeEmailAsync(user); + await SendWelcomeEmailAsync(user); } return result; @@ -357,4 +386,59 @@ public class RegisterUserCommand : IRegisterUserCommand return tokenable; } + + /// + /// We send different welcome emails depending on whether the user is joining a free/family or an enterprise organization. If information to populate the + /// email isn't present we send the standard individual welcome email. + /// + /// Target user for the email + /// this value is nullable + /// + private async Task SendWelcomeEmailAsync(User user, Organization? organization = null) + { + // Check if feature is enabled + // TODO: Remove Feature flag: PM-28221 + if (!_featureService.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)) + { + await _mailService.SendWelcomeEmailAsync(user); + return; + } + + // Most emails are probably for non organization users so we default to that experience + if (organization == null) + { + await _mailService.SendIndividualUserWelcomeEmailAsync(user); + } + // We need to make sure that the organization email has the correct data to display otherwise we just send the standard welcome email + else if (!string.IsNullOrEmpty(organization.DisplayName())) + { + // If the organization is Free or Families plan, send families welcome email + if (organization.PlanType is PlanType.FamiliesAnnually + or PlanType.FamiliesAnnually2019 + or PlanType.Free) + { + await _mailService.SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(user, organization.DisplayName()); + } + else + { + await _mailService.SendOrganizationUserWelcomeEmailAsync(user, organization.DisplayName()); + } + } + // If the organization data isn't present send the standard welcome email + else + { + await _mailService.SendIndividualUserWelcomeEmailAsync(user); + } + } + + private async Task GetOrganizationUserOrganization(Guid orgUserId, OrganizationUser? orgUser = null) + { + var organizationUser = orgUser ?? await _organizationUserRepository.GetByIdAsync(orgUserId); + if (organizationUser == null) + { + return null; + } + + return await _organizationRepository.GetByIdAsync(organizationUser.OrganizationId); + } } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 3a48380e87..d8602e2617 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -162,6 +162,7 @@ public static class FeatureFlagKeys "pm-23174-manage-account-recovery-permission-drives-the-need-to-set-master-password"; public const string RecoveryCodeSupportForSsoRequiredUsers = "pm-21153-recovery-code-support-for-sso-required"; public const string MJMLBasedEmailTemplates = "mjml-based-email-templates"; + public const string MjmlWelcomeEmailTemplates = "mjml-welcome-email-templates"; /* Autofill Team */ public const string IdpAutoSubmitLogin = "idp-auto-submit-login"; diff --git a/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs index fad0af840d..f9cc04f73e 100644 --- a/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs +++ b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs @@ -29,8 +29,8 @@ .mj-outlook-group-fix { width:100% !important; } - - + + - - - - + + + + - + - + - - + +
- + - - + +
- +
- +
- - + + - - + +
- +
- +
- + - + - + - +
- +
- + - +
- +
- +

Verify your email to access this Bitwarden Send

- +
- +
- + - +
- + - + - - +
- + +
- + - +
- +
- +
- +
- +
- - + + - - + +
- +
- +
- - + + - + - + - - + +
- +
- - + +
- +
- +
- +
- + - + - + - + - + - +
- +
Your verification code is:
- +
- +
{{Token}}
- +
- +
- +
- -
This code expires in {{Expiry}} minutes. After that, you'll need to - verify your email again.
- + +
This code expires in {{Expiry}} minutes. After that, you'll need + to verify your email again.
+
- +
- +
- +
- +
- - + + - - + +
- +
- +
- +
- + - + - +
- +

Bitwarden Send transmits sensitive, temporary information to others easily and securely. Learn more about @@ -325,160 +333,160 @@ sign up to try it today.

- +
- +
- +
- +
- +
- - + +
- +
- - + + - + - + - - + +
- +
- - + +
- +
- +
- + - + - +
- +

- Learn more about Bitwarden -

- Find user guides, product documentation, and videos on the - Bitwarden Help Center.
- + Learn more about Bitwarden +

+ Find user guides, product documentation, and videos on the + Bitwarden Help Center. +
- +
- + - +
- + - + - - +
- +
- +
- +
- +
- - + +
- +
- - + + - + - + - - + +
- +
- +
- + - + - + - +
- - + + - + - + - +
@@ -493,15 +501,15 @@
- + - + - +
@@ -516,15 +524,15 @@
- + - + - +
@@ -539,15 +547,15 @@
- + - + - +
@@ -562,15 +570,15 @@
- + - + - +
@@ -585,15 +593,15 @@
- + - + - +
@@ -608,15 +616,15 @@
- + - + - +
@@ -631,20 +639,20 @@
- - + +
- +

© 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa Barbara, CA, USA @@ -655,28 +663,29 @@ bitwarden.com | Learn why we include this

- +
- +
- +
- +
- - + + - - + +
- + + \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.html.hbs b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.html.hbs new file mode 100644 index 0000000000..3cbc9446c8 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.html.hbs @@ -0,0 +1,915 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +

+ Welcome to Bitwarden! +

+ +

+ Let's get set up to autofill. +

+
+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +
A {{OrganizationName}} administrator will approve you + before you can share passwords. While you wait for approval, get + started with Bitwarden Password Manager:
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Browser Extension Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + +
+ +
With the Bitwarden extension, you can fill passwords with one click.
+ +
+ +
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Install Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + +
+ +
Quickly transfer existing passwords to Bitwarden using the importer.
+ +
+ +
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Devices Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + +
+ +
Take your passwords with you anywhere.
+ +
+ +
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +

+ 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 +

+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.text.hbs b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.text.hbs new file mode 100644 index 0000000000..38f53e7755 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.text.hbs @@ -0,0 +1,19 @@ +{{#>FullTextLayout}} +Welcome to Bitwarden! +Let's get you set up with autofill. + +A {{OrganizationName}} administrator will approve you before you can share passwords. +While you wait for approval, get started with Bitwarden Password Manager: + +Get the browser extension: +With the Bitwarden extension, you can fill passwords with one click. (https://www.bitwarden.com/download) + +Add passwords to your vault: +Quickly transfer existing passwords to Bitwarden using the importer. (https://bitwarden.com/help/import-data/) + +Download Bitwarden on all devices: +Take your passwords with you anywhere. (https://www.bitwarden.com/download) + +Learn more about Bitwarden +Find user guides, product documentation, and videos on the Bitwarden Help Center. (https://bitwarden.com/help/) +{{/FullTextLayout}} diff --git a/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-individual-user.html.hbs b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-individual-user.html.hbs new file mode 100644 index 0000000000..d77542bfb6 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-individual-user.html.hbs @@ -0,0 +1,914 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +

+ Welcome to Bitwarden! +

+ +

+ Let's get set up to autofill. +

+
+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +
Follow these simple steps to get up and running with Bitwarden + Password Manager:
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Browser Extension Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + +
+ +
With the Bitwarden extension, you can fill passwords with one click.
+ +
+ +
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Install Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + +
+ +
Quickly transfer existing passwords to Bitwarden using the importer.
+ +
+ +
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Devices Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + +
+ +
Take your passwords with you anywhere.
+ +
+ +
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +

+ 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 +

+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-individual-user.text.hbs b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-individual-user.text.hbs new file mode 100644 index 0000000000..f698e79ca7 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-individual-user.text.hbs @@ -0,0 +1,18 @@ +{{#>FullTextLayout}} +Welcome to Bitwarden! +Let's get you set up with autofill. + +Follow these simple steps to get up and running with Bitwarden Password Manager: + +Get the browser extension: +With the Bitwarden extension, you can fill passwords with one click. (https://www.bitwarden.com/download) + +Add passwords to your vault: +Quickly transfer existing passwords to Bitwarden using the importer. (https://bitwarden.com/help/import-data/) + +Download Bitwarden on all devices: +Take your passwords with you anywhere. (https://bitwarden.com/help/auto-fill-browser/) + +Learn more about Bitwarden +Find user guides, product documentation, and videos on the Bitwarden Help Center. (https://bitwarden.com/help/) +{{/FullTextLayout}} diff --git a/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-org-user.html.hbs b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-org-user.html.hbs new file mode 100644 index 0000000000..2b1141caad --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-org-user.html.hbs @@ -0,0 +1,915 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +

+ Welcome to Bitwarden! +

+ +

+ Let's get set up to autofill. +

+
+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +
A {{OrganizationName}} administrator will need to confirm + you before you can share passwords. Get started with Bitwarden + Password Manager:
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Browser Extension Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + +
+ +
With the Bitwarden extension, you can fill passwords with one click.
+ +
+ +
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Install Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + +
+ +
Quickly transfer existing passwords to Bitwarden using the importer.
+ +
+ +
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Autofill Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + +
+ +
Fill your passwords securely with one click.
+ +
+ +
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +

+ 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 +

+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-org-user.text.hbs b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-org-user.text.hbs new file mode 100644 index 0000000000..3808cc818d --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-org-user.text.hbs @@ -0,0 +1,20 @@ +{{#>FullTextLayout}} +Welcome to Bitwarden! +Let's get you set up with autofill. + +A {{OrganizationName}} administrator will approve you before you can share passwords. +Get started with Bitwarden Password Manager: + +Get the browser extension: +With the Bitwarden extension, you can fill passwords with one click. (https://www.bitwarden.com/download) + +Add passwords to your vault: +Quickly transfer existing passwords to Bitwarden using the importer. (https://bitwarden.com/help/import-data/) + +Try Bitwarden autofill: +Fill your passwords securely with one click. (https://bitwarden.com/help/auto-fill-browser/) + + +Learn more about Bitwarden +Find user guides, product documentation, and videos on the Bitwarden Help Center. (https://bitwarden.com/help/) +{{/FullTextLayout}} diff --git a/src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/welcome-free-user.mjml b/src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/welcome-individual-user.mjml similarity index 100% rename from src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/welcome-free-user.mjml rename to src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/welcome-individual-user.mjml diff --git a/src/Core/MailTemplates/Mjml/emails/Auth/send-email-otp.mjml b/src/Core/MailTemplates/Mjml/emails/Auth/send-email-otp.mjml index d3d4eb9891..660bbf0b45 100644 --- a/src/Core/MailTemplates/Mjml/emails/Auth/send-email-otp.mjml +++ b/src/Core/MailTemplates/Mjml/emails/Auth/send-email-otp.mjml @@ -1,7 +1,13 @@ - + + .send-bubble { + padding-left: 20px; + padding-right: 20px; + width: 90% !important; + } + @@ -18,18 +24,17 @@ Your verification code is: - {{Token}} + + {{Token}} + - This code expires in {{Expiry}} minutes. After that, you'll need to - verify your email again. + This code expires in {{Expiry}} minutes. After that, you'll need + to verify your email again. - + + /// Email sent to users who have created a new account as an individual user. + ///
+ /// The new User + /// Task + Task SendIndividualUserWelcomeEmailAsync(User user); + /// + /// Email sent to users who have been confirmed to an organization. + /// + /// The User + /// The Organization user is being added to + /// Task + Task SendOrganizationUserWelcomeEmailAsync(User user, string organizationName); + /// + /// Email sent to users who have been confirmed to a free or families organization. + /// + /// The User + /// The Families Organization user is being added to + /// Task + Task SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(User user, string familyOrganizationName); Task SendVerifyEmailEmailAsync(string email, Guid userId, string token); Task SendRegistrationVerificationEmailAsync(string email, string token); Task SendTrialInitiationSignupEmailAsync( diff --git a/src/Core/Platform/Mail/NoopMailService.cs b/src/Core/Platform/Mail/NoopMailService.cs index 45a860a155..da55470db3 100644 --- a/src/Core/Platform/Mail/NoopMailService.cs +++ b/src/Core/Platform/Mail/NoopMailService.cs @@ -114,6 +114,20 @@ public class NoopMailService : IMailService return Task.FromResult(0); } + public Task SendIndividualUserWelcomeEmailAsync(User user) + { + return Task.FromResult(0); + } + + public Task SendOrganizationUserWelcomeEmailAsync(User user, string organizationName) + { + return Task.FromResult(0); + } + + public Task SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(User user, string familyOrganizationName) + { + return Task.FromResult(0); + } public Task SendVerifyDeleteEmailAsync(string email, Guid userId, string token) { return Task.FromResult(0); diff --git a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs index b19ae47cfc..16a48b12e3 100644 --- a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs +++ b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs @@ -7,6 +7,7 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.UserFeatures.Registration.Implementations; +using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; @@ -80,6 +81,120 @@ public class RegisterUserCommandTests .SendWelcomeEmailAsync(Arg.Any()); } + // ----------------------------------------------------------------------------------------------- + // RegisterSSOAutoProvisionedUserAsync tests + // ----------------------------------------------------------------------------------------------- + [Theory, BitAutoData] + public async Task RegisterSSOAutoProvisionedUserAsync_Success( + User user, + Organization organization, + SutProvider sutProvider) + { + // Arrange + user.Id = Guid.NewGuid(); + organization.Id = Guid.NewGuid(); + organization.Name = "Test Organization"; + + sutProvider.GetDependency() + .CreateUserAsync(user) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates) + .Returns(true); + + // Act + var result = await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization); + + // Assert + Assert.True(result.Succeeded); + await sutProvider.GetDependency() + .Received(1) + .CreateUserAsync(user); + } + + [Theory, BitAutoData] + public async Task RegisterSSOAutoProvisionedUserAsync_UserRegistrationFails_ReturnsFailedResult( + User user, + Organization organization, + SutProvider sutProvider) + { + // Arrange + var expectedError = new IdentityError(); + sutProvider.GetDependency() + .CreateUserAsync(user) + .Returns(IdentityResult.Failed(expectedError)); + + // Act + var result = await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization); + + // Assert + Assert.False(result.Succeeded); + Assert.Contains(expectedError, result.Errors); + await sutProvider.GetDependency() + .DidNotReceive() + .SendOrganizationUserWelcomeEmailAsync(Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData(PlanType.EnterpriseAnnually)] + [BitAutoData(PlanType.EnterpriseMonthly)] + [BitAutoData(PlanType.TeamsAnnually)] + public async Task RegisterSSOAutoProvisionedUserAsync_EnterpriseOrg_SendsOrganizationWelcomeEmail( + PlanType planType, + User user, + Organization organization, + SutProvider sutProvider) + { + // Arrange + organization.PlanType = planType; + organization.Name = "Enterprise Org"; + + sutProvider.GetDependency() + .CreateUserAsync(user) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates) + .Returns(true); + + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns((OrganizationUser)null); + + // Act + await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SendOrganizationUserWelcomeEmailAsync(user, organization.Name); + } + + [Theory, BitAutoData] + public async Task RegisterSSOAutoProvisionedUserAsync_FeatureFlagDisabled_SendsLegacyWelcomeEmail( + User user, + Organization organization, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .CreateUserAsync(user) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates) + .Returns(false); + + // Act + await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SendWelcomeEmailAsync(user); + } + // ----------------------------------------------------------------------------------------------- // RegisterUserWithOrganizationInviteToken tests // ----------------------------------------------------------------------------------------------- @@ -646,5 +761,186 @@ public class RegisterUserCommandTests Assert.Equal("Open registration has been disabled by the system administrator.", result.Message); } + // ----------------------------------------------------------------------------------------------- + // SendWelcomeEmail tests + // ----------------------------------------------------------------------------------------------- + [Theory] + [BitAutoData(PlanType.FamiliesAnnually)] + [BitAutoData(PlanType.FamiliesAnnually2019)] + [BitAutoData(PlanType.Free)] + public async Task SendWelcomeEmail_FamilyOrg_SendsFamilyWelcomeEmail( + PlanType planType, + User user, + Organization organization, + SutProvider sutProvider) + { + // Arrange + organization.PlanType = planType; + organization.Name = "Family Org"; + sutProvider.GetDependency() + .CreateUserAsync(user) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates) + .Returns(true); + + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns((OrganizationUser)null); + + // Act + await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(user, organization.Name); + } + + [Theory] + [BitAutoData] + public async Task SendWelcomeEmail_OrganizationNull_SendsIndividualWelcomeEmail( + User user, + OrganizationUser orgUser, + string orgInviteToken, + string masterPasswordHash, + SutProvider sutProvider) + { + // Arrange + user.ReferenceData = null; + orgUser.Email = user.Email; + + sutProvider.GetDependency() + .CreateUserAsync(user, masterPasswordHash) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .GetByIdAsync(orgUser.Id) + .Returns(orgUser); + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(Arg.Any(), PolicyType.TwoFactorAuthentication) + .Returns((Policy)null); + + sutProvider.GetDependency() + .GetByIdAsync(orgUser.OrganizationId) + .Returns((Organization)null); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates) + .Returns(true); + + var orgInviteTokenable = new OrgUserInviteTokenable(orgUser); + + sutProvider.GetDependency>() + .TryUnprotect(orgInviteToken, out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = orgInviteTokenable; + return true; + }); + + // Act + var result = await sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, masterPasswordHash, orgInviteToken, orgUser.Id); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SendIndividualUserWelcomeEmailAsync(user); + } + + [Theory] + [BitAutoData] + public async Task SendWelcomeEmail_OrganizationDisplayNameNull_SendsIndividualWelcomeEmail( + User user, + SutProvider sutProvider) + { + // Arrange + Organization organization = new Organization + { + Name = null + }; + + sutProvider.GetDependency() + .CreateUserAsync(user) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates) + .Returns(true); + + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns((OrganizationUser)null); + + // Act + await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SendIndividualUserWelcomeEmailAsync(user); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationWelcomeEmailDetailsAsync_HappyPath_ReturnsOrganizationWelcomeEmailDetails( + Organization organization, + User user, + OrganizationUser orgUser, + string masterPasswordHash, + string orgInviteToken, + SutProvider sutProvider) + { + // Arrange + user.ReferenceData = null; + orgUser.Email = user.Email; + organization.PlanType = PlanType.EnterpriseAnnually; + + sutProvider.GetDependency() + .CreateUserAsync(user, masterPasswordHash) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .GetByIdAsync(orgUser.Id) + .Returns(orgUser); + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(Arg.Any(), PolicyType.TwoFactorAuthentication) + .Returns((Policy)null); + + sutProvider.GetDependency() + .GetByIdAsync(orgUser.OrganizationId) + .Returns(organization); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates) + .Returns(true); + + var orgInviteTokenable = new OrgUserInviteTokenable(orgUser); + + sutProvider.GetDependency>() + .TryUnprotect(orgInviteToken, out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = orgInviteTokenable; + return true; + }); + + // Act + var result = await sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, masterPasswordHash, orgInviteToken, orgUser.Id); + + // Assert + Assert.True(result.Succeeded); + + await sutProvider.GetDependency() + .Received(1) + .GetByIdAsync(orgUser.OrganizationId); + + await sutProvider.GetDependency() + .Received(1) + .SendOrganizationUserWelcomeEmailAsync(user, organization.DisplayName()); + } } diff --git a/test/Core.Test/Services/HandlebarsMailServiceTests.cs b/test/Core.Test/Services/HandlebarsMailServiceTests.cs index d624bebf51..b98c4580f5 100644 --- a/test/Core.Test/Services/HandlebarsMailServiceTests.cs +++ b/test/Core.Test/Services/HandlebarsMailServiceTests.cs @@ -268,4 +268,115 @@ public class HandlebarsMailServiceTests // Assert await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Any()); } + + [Fact] + public async Task SendIndividualUserWelcomeEmailAsync_SendsCorrectEmail() + { + // Arrange + var user = new User + { + Id = Guid.NewGuid(), + Email = "test@example.com" + }; + + // Act + await _sut.SendIndividualUserWelcomeEmailAsync(user); + + // Assert + await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Is(m => + m.MetaData != null && + m.ToEmails.Contains("test@example.com") && + m.Subject == "Welcome to Bitwarden!" && + m.Category == "Welcome")); + } + + [Fact] + public async Task SendOrganizationUserWelcomeEmailAsync_SendsCorrectEmailWithOrganizationName() + { + // Arrange + var user = new User + { + Id = Guid.NewGuid(), + Email = "user@company.com" + }; + var organizationName = "Bitwarden Corp"; + + // Act + await _sut.SendOrganizationUserWelcomeEmailAsync(user, organizationName); + + // Assert + await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Is(m => + m.MetaData != null && + m.ToEmails.Contains("user@company.com") && + m.Subject == "Welcome to Bitwarden!" && + m.HtmlContent.Contains("Bitwarden Corp") && + m.Category == "Welcome")); + } + + [Fact] + public async Task SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync_SendsCorrectEmailWithFamilyTemplate() + { + // Arrange + var user = new User + { + Id = Guid.NewGuid(), + Email = "family@example.com" + }; + var familyOrganizationName = "Smith Family"; + + // Act + await _sut.SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(user, familyOrganizationName); + + // Assert + await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Is(m => + m.MetaData != null && + m.ToEmails.Contains("family@example.com") && + m.Subject == "Welcome to Bitwarden!" && + m.HtmlContent.Contains("Smith Family") && + m.Category == "Welcome")); + } + + [Theory] + [InlineData("Acme Corp", "Acme Corp")] + [InlineData("Company & Associates", "Company & Associates")] + [InlineData("Test \"Quoted\" Org", "Test "Quoted" Org")] + public async Task SendOrganizationUserWelcomeEmailAsync_SanitizesOrganizationNameForEmail(string inputOrgName, string expectedSanitized) + { + // Arrange + var user = new User + { + Id = Guid.NewGuid(), + Email = "test@example.com" + }; + + // Act + await _sut.SendOrganizationUserWelcomeEmailAsync(user, inputOrgName); + + // Assert + await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Is(m => + m.HtmlContent.Contains(expectedSanitized) && + !m.HtmlContent.Contains("