diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs index 00bac01f7609..00ba706a415f 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs @@ -20,6 +20,12 @@ public class OrganizationUserUserDetails : IExternal, ITwoFactorProvidersUser, I public string Email { get; set; } public string AvatarColor { get; set; } public string TwoFactorProviders { get; set; } + /// + /// Indicates whether the user has a personal premium subscription. + /// Does not include premium access from organizations - + /// do not use this to check whether the user can access premium features. + /// Null when the organization user is in Invited status (UserId is null). + /// public bool? Premium { get; set; } public OrganizationUserStatusType Status { get; set; } public OrganizationUserType Type { get; set; } diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/Interfaces/ITwoFactorIsEnabledQuery.cs b/src/Core/Auth/UserFeatures/TwoFactorAuth/Interfaces/ITwoFactorIsEnabledQuery.cs index 697c10690cd9..406b39cd69c2 100644 --- a/src/Core/Auth/UserFeatures/TwoFactorAuth/Interfaces/ITwoFactorIsEnabledQuery.cs +++ b/src/Core/Auth/UserFeatures/TwoFactorAuth/Interfaces/ITwoFactorIsEnabledQuery.cs @@ -1,4 +1,5 @@ using Bit.Core.Auth.Models; +using Bit.Core.Entities; namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; @@ -22,4 +23,25 @@ public interface ITwoFactorIsEnabledQuery /// /// The user to check. Task TwoFactorIsEnabledAsync(ITwoFactorProvidersUser user); + + /// + /// Returns a list of user IDs and whether two factor is enabled for each user. + /// This version uses HasPremiumAccessQuery with cached organization abilities for better performance. + /// + /// The list of user IDs to check. + Task> TwoFactorIsEnabledVNextAsync(IEnumerable userIds); + /// + /// Returns a list of users and whether two factor is enabled for each user. + /// This version uses HasPremiumAccessQuery with cached organization abilities for better performance. + /// + /// The list of users to check. + /// The type of user in the list. Must implement . + Task> TwoFactorIsEnabledVNextAsync(IEnumerable users) where T : ITwoFactorProvidersUser; + /// + /// Returns whether two factor is enabled for the user. A user is able to have a TwoFactorProvider that is enabled but requires Premium. + /// If the user does not have premium then the TwoFactorProvider is considered _not_ enabled. + /// This version uses HasPremiumAccessQuery with cached organization abilities for better performance. + /// + /// The user to check. + Task TwoFactorIsEnabledVNextAsync(User user); } diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs b/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs index cc86d3d71d39..de8df635a2f5 100644 --- a/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs +++ b/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs @@ -4,13 +4,24 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Billing.Premium.Queries; +using Bit.Core.Entities; using Bit.Core.Repositories; namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth; -public class TwoFactorIsEnabledQuery(IUserRepository userRepository) : ITwoFactorIsEnabledQuery +public class TwoFactorIsEnabledQuery : ITwoFactorIsEnabledQuery { - private readonly IUserRepository _userRepository = userRepository; + private readonly IUserRepository _userRepository; + private readonly IHasPremiumAccessQuery _hasPremiumAccessQuery; + + public TwoFactorIsEnabledQuery( + IUserRepository userRepository, + IHasPremiumAccessQuery hasPremiumAccessQuery) + { + _userRepository = userRepository; + _hasPremiumAccessQuery = hasPremiumAccessQuery; + } public async Task> TwoFactorIsEnabledAsync(IEnumerable userIds) { @@ -72,12 +83,113 @@ public async Task TwoFactorIsEnabledAsync(ITwoFactorProvidersUser user) } return await TwoFactorEnabledAsync( - user.GetTwoFactorProviders(), - async () => - { - var calcUser = await _userRepository.GetCalculatedPremiumAsync(userId.Value); - return calcUser?.HasPremiumAccess ?? false; - }); + user.GetTwoFactorProviders(), + async () => + { + var calcUser = await _userRepository.GetCalculatedPremiumAsync(userId.Value); + return calcUser?.HasPremiumAccess ?? false; + }); + } + + public async Task> TwoFactorIsEnabledVNextAsync(IEnumerable userIds) + { + var result = new List<(Guid userId, bool hasTwoFactor)>(); + if (userIds == null || !userIds.Any()) + { + return result; + } + + var users = await _userRepository.GetManyAsync([.. userIds]); + var premiumStatus = await _hasPremiumAccessQuery.HasPremiumAccessAsync(userIds); + + foreach (var user in users) + { + var twoFactorProviders = user.GetTwoFactorProviders(); + var hasPremiumAccess = premiumStatus.GetValueOrDefault(user.Id, false); + var twoFactorIsEnabled = TwoFactorIsEnabled(twoFactorProviders, hasPremiumAccess); + + result.Add((user.Id, twoFactorIsEnabled)); + } + + return result; + } + + public async Task> TwoFactorIsEnabledVNextAsync(IEnumerable users) + where T : ITwoFactorProvidersUser + { + var userIds = users + .Select(u => u.GetUserId()) + .Where(u => u.HasValue) + .Select(u => u.Value) + .ToList(); + + var twoFactorResults = await TwoFactorIsEnabledVNextAsync(userIds); + + var result = new List<(T user, bool twoFactorIsEnabled)>(); + + foreach (var user in users) + { + var userId = user.GetUserId(); + if (userId.HasValue) + { + var hasTwoFactor = twoFactorResults.FirstOrDefault(res => res.userId == userId.Value).twoFactorIsEnabled; + result.Add((user, hasTwoFactor)); + } + else + { + result.Add((user, false)); + } + } + + return result; + } + + public async Task TwoFactorIsEnabledVNextAsync(User user) + { + var providers = user.GetTwoFactorProviders(); + var hasPremium = await _hasPremiumAccessQuery.HasPremiumAccessAsync(user.Id); + + return TwoFactorIsEnabled(providers, hasPremium); + } + + /// + /// Checks to see what kind of two-factor is enabled. + /// Synchronous version used when premium access status is already known. + /// + /// dictionary of two factor providers + /// whether the user has premium access + /// true if the user has two factor enabled; false otherwise + private static bool TwoFactorIsEnabled( + Dictionary providers, + bool hasPremiumAccess) + { + // If there are no providers, then two factor is not enabled + if (providers == null || providers.Count == 0) + { + return false; + } + + // Get all enabled providers + // TODO: PM-21210: In practice we don't save disabled providers to the database, worth looking into. + var enabledProviderKeys = from provider in providers + where provider.Value?.Enabled ?? false + select provider.Key; + + // If no providers are enabled then two factor is not enabled + if (!enabledProviderKeys.Any()) + { + return false; + } + + // If there are only premium two factor options then check premium access + var onlyHasPremiumTwoFactor = enabledProviderKeys.All(TwoFactorProvider.RequiresPremium); + if (onlyHasPremiumTwoFactor) + { + return hasPremiumAccess; + } + + // The user has at least one non-premium two factor option + return true; } /// diff --git a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs index 7c50f7f17be3..07669c623882 100644 --- a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs +++ b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs @@ -9,6 +9,7 @@ using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Auth.UserFeatures.WebAuthnLogin; using Bit.Core.Auth.UserFeatures.WebAuthnLogin.Implementations; +using Bit.Core.Billing.Premium.Queries; using Bit.Core.KeyManagement.UserKey; using Bit.Core.KeyManagement.UserKey.Implementations; using Bit.Core.Services; @@ -27,6 +28,7 @@ public static void AddUserServices(this IServiceCollection services, IGlobalSett services.AddUserRegistrationCommands(); services.AddWebAuthnLoginCommands(); services.AddTdeOffboardingPasswordCommands(); + services.AddPremiumAccessQueries(); services.AddTwoFactorQueries(); services.AddSsoQueries(); } @@ -65,6 +67,11 @@ private static void AddWebAuthnLoginCommands(this IServiceCollection services) services.AddScoped(); } + private static void AddPremiumAccessQueries(this IServiceCollection services) + { + services.AddScoped(); + } + private static void AddTwoFactorQueries(this IServiceCollection services) { services.AddScoped(); diff --git a/src/Core/Billing/Premium/Queries/HasPremiumAccessQuery.cs b/src/Core/Billing/Premium/Queries/HasPremiumAccessQuery.cs new file mode 100644 index 000000000000..c047012380eb --- /dev/null +++ b/src/Core/Billing/Premium/Queries/HasPremiumAccessQuery.cs @@ -0,0 +1,44 @@ +using Bit.Core.Repositories; + +namespace Bit.Core.Billing.Premium.Queries; + +/// +/// Query for checking premium access status for users using the existing stored procedure +/// that calculates premium access from personal subscriptions and organization memberships. +/// +public class HasPremiumAccessQuery : IHasPremiumAccessQuery +{ + private readonly IUserRepository _userRepository; + + public HasPremiumAccessQuery(IUserRepository userRepository) + { + _userRepository = userRepository; + } + + public async Task HasPremiumAccessAsync(Guid userId) + { + var user = await _userRepository.GetCalculatedPremiumAsync(userId); + return user?.HasPremiumAccess ?? false; + } + + public async Task> HasPremiumAccessAsync(IEnumerable userIds) + { + var usersWithPremium = await _userRepository.GetManyWithCalculatedPremiumAsync(userIds); + return usersWithPremium.ToDictionary(u => u.Id, u => u.HasPremiumAccess); + } + + public async Task HasPremiumFromOrganizationAsync(Guid userId) + { + var user = await _userRepository.GetCalculatedPremiumAsync(userId); + if (user == null) + { + return false; + } + + // Has org premium if has premium access but not personal premium + return user.HasPremiumAccess && !user.Premium; + } +} + + + diff --git a/src/Core/Billing/Premium/Queries/IHasPremiumAccessQuery.cs b/src/Core/Billing/Premium/Queries/IHasPremiumAccessQuery.cs new file mode 100644 index 000000000000..95c7019b361b --- /dev/null +++ b/src/Core/Billing/Premium/Queries/IHasPremiumAccessQuery.cs @@ -0,0 +1,41 @@ +namespace Bit.Core.Billing.Premium.Queries; + +/// +/// Query for checking premium access status for users. +/// This is the centralized location for determining if a user can access premium features +/// (either through personal subscription or organization membership). +/// +/// +/// Note: This is different from checking User.Premium, which only indicates +/// personal subscription status. Use these methods to check actual premium feature access. +/// +/// +public interface IHasPremiumAccessQuery +{ + /// + /// Checks if a user has access to premium features (personal subscription or organization). + /// This is the definitive way to check premium access for a single user. + /// + /// The user ID to check for premium access + /// True if user can access premium features; false otherwise + Task HasPremiumAccessAsync(Guid userId); + + /// + /// Checks if a user has access to premium features through organization membership only. + /// This is useful for determining the source of premium access (personal vs organization). + /// + /// The user ID to check for organization premium access + /// True if user has premium access through any organization; false otherwise + Task HasPremiumFromOrganizationAsync(Guid userId); + + /// + /// Checks if multiple users have access to premium features (optimized bulk operation). + /// Uses existing stored procedure that calculates premium from personal subscriptions and organizations. + /// + /// The user IDs to check for premium access + /// Dictionary mapping user IDs to their premium access status (personal or through organization) + Task> HasPremiumAccessAsync(IEnumerable userIds); +} + + + diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index af5b738cd0ab..c3f3cea76ae9 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -144,6 +144,7 @@ public static class FeatureFlagKeys public const string BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration"; public const string PolicyValidatorsRefactor = "pm-26423-refactor-policy-side-effects"; public const string IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud"; + public const string PremiumAccessQuery = "pm-21411-premium-access-query"; /* Architecture */ public const string DesktopMigrationMilestone1 = "desktop-ui-migration-milestone-1"; diff --git a/src/Core/Entities/User.cs b/src/Core/Entities/User.cs index 1ca660677914..669e32bcbe8b 100644 --- a/src/Core/Entities/User.cs +++ b/src/Core/Entities/User.cs @@ -69,6 +69,11 @@ public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFac /// The security state is a signed object attesting to the version of the user's account. /// public string? SecurityState { get; set; } + /// + /// Indicates whether the user has a personal premium subscription. + /// Does not include premium access from organizations - + /// do not use this to check whether the user can access premium features. + /// public bool Premium { get; set; } public DateTime? PremiumExpirationDate { get; set; } public DateTime? RenewalReminderDate { get; set; } diff --git a/src/Core/Repositories/IUserRepository.cs b/src/Core/Repositories/IUserRepository.cs index 22effb4329d5..db9151e0ddb1 100644 --- a/src/Core/Repositories/IUserRepository.cs +++ b/src/Core/Repositories/IUserRepository.cs @@ -23,6 +23,7 @@ public interface IUserRepository : IRepository /// Retrieves the data for the requested user IDs and includes an additional property indicating /// whether the user has premium access directly or through an organization. /// + [Obsolete("Use IUserService.CanAccessPremiumBulk instead. This method is only used when feature flag 'PremiumAccessQuery' is disabled.")] Task> GetManyWithCalculatedPremiumAsync(IEnumerable ids); /// /// Retrieves the data for the requested user ID and includes additional property indicating @@ -33,6 +34,7 @@ public interface IUserRepository : IRepository /// /// The user ID to retrieve data for. /// User data with calculated premium access; null if nothing is found + [Obsolete("Use IUserService.CanAccessPremium instead. This method is only used when feature flag 'PremiumAccessQuery' is disabled.")] Task GetCalculatedPremiumAsync(Guid userId); /// /// Sets a new user key and updates all encrypted data. diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index 70c62cb79ee5..c324a8b1d93f 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -64,6 +64,7 @@ Task GenerateLicenseAsync(User user, SubscriptionInfo subscriptionI /// /// user being acted on /// true if they can access premium; false otherwise. + [Obsolete("Use IHasPremiumAccessQuery.HasPremiumAccessAsync instead. This method is only used when feature flag 'PremiumAccessQuery' is disabled.")] Task CanAccessPremium(User user); /// @@ -74,6 +75,7 @@ Task GenerateLicenseAsync(User user, SubscriptionInfo subscriptionI /// /// /// + [Obsolete("Use IHasPremiumAccessQuery.HasPremiumFromOrganizationAsync instead. This method is only used when feature flag 'PremiumAccessQuery' is disabled.")] Task HasPremiumFromOrganization(User user); Task GenerateSignInTokenAsync(User user, string purpose); diff --git a/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs b/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs index adeac45d062a..0856c3377fbc 100644 --- a/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs +++ b/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs @@ -1,6 +1,7 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.UserFeatures.TwoFactorAuth; +using Bit.Core.Billing.Premium.Queries; using Bit.Core.Entities; using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations.OrganizationUsers; @@ -404,6 +405,407 @@ await sutProvider.GetDependency() .GetCalculatedPremiumAsync(default); } + [Theory] + [BitAutoData(TwoFactorProviderType.Authenticator)] + [BitAutoData(TwoFactorProviderType.Email)] + [BitAutoData(TwoFactorProviderType.Remember)] + [BitAutoData(TwoFactorProviderType.OrganizationDuo)] + [BitAutoData(TwoFactorProviderType.WebAuthn)] + public async Task TwoFactorIsEnabledVNextAsync_WithProviderTypeNotRequiringPremium_ReturnsAllTwoFactorEnabled( + TwoFactorProviderType freeProviderType, + SutProvider sutProvider, + List users) + { + // Arrange + var userIds = users.Select(u => u.Id).ToList(); + var twoFactorProviders = new Dictionary + { + { freeProviderType, new TwoFactorProvider { Enabled = true } } + }; + + foreach (var user in users) + { + user.Premium = false; + user.SetTwoFactorProviders(twoFactorProviders); + } + + var premiumStatus = users.ToDictionary(u => u.Id, u => false); + + sutProvider.GetDependency() + .GetManyAsync(Arg.Is>(i => i.All(userIds.Contains))) + .Returns(users); + + sutProvider.GetDependency() + .HasPremiumAccessAsync(userIds) + .Returns(premiumStatus); + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledVNextAsync(userIds); + + // Assert + foreach (var user in users) + { + Assert.Contains(result, res => res.userId == user.Id && res.twoFactorIsEnabled == true); + } + } + + [Theory, BitAutoData] + public async Task TwoFactorIsEnabledVNextAsync_DatabaseReturnsEmpty_ResultEmpty( + SutProvider sutProvider, + List users) + { + // Arrange + var userIds = users.Select(u => u.Id).ToList(); + + sutProvider.GetDependency() + .GetManyAsync(Arg.Any>()) + .Returns([]); + + sutProvider.GetDependency() + .HasPremiumAccessAsync(Arg.Any>()) + .Returns(new Dictionary()); + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledVNextAsync(userIds); + + // Assert + Assert.Empty(result); + } + + [Theory] + [BitAutoData((IEnumerable)null)] + [BitAutoData([])] + public async Task TwoFactorIsEnabledVNextAsync_UserIdsNullorEmpty_ResultEmpty( + IEnumerable userIds, + SutProvider sutProvider) + { + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledVNextAsync(userIds); + + // Assert + Assert.Empty(result); + } + + [Theory] + [BitAutoData] + public async Task TwoFactorIsEnabledVNextAsync_WithNoTwoFactorEnabled_ReturnsAllTwoFactorDisabled( + SutProvider sutProvider, + List users) + { + // Arrange + var userIds = users.Select(u => u.Id).ToList(); + var twoFactorProviders = new Dictionary + { + { TwoFactorProviderType.Email, new TwoFactorProvider { Enabled = false } } + }; + + foreach (var user in users) + { + user.SetTwoFactorProviders(twoFactorProviders); + } + + var premiumStatus = users.ToDictionary(u => u.Id, u => false); + + sutProvider.GetDependency() + .GetManyAsync(Arg.Is>(i => i.All(userIds.Contains))) + .Returns(users); + + sutProvider.GetDependency() + .HasPremiumAccessAsync(userIds) + .Returns(premiumStatus); + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledVNextAsync(userIds); + + // Assert + foreach (var user in users) + { + Assert.Contains(result, res => res.userId == user.Id && res.twoFactorIsEnabled == false); + } + } + + [Theory] + [BitAutoData(TwoFactorProviderType.Duo)] + [BitAutoData(TwoFactorProviderType.YubiKey)] + public async Task TwoFactorIsEnabledVNextAsync_WithProviderTypeRequiringPremium_ReturnsMixedResults( + TwoFactorProviderType premiumProviderType, + SutProvider sutProvider, + List users) + { + // Arrange + var userIds = users.Select(u => u.Id).ToList(); + var twoFactorProviders = new Dictionary + { + { TwoFactorProviderType.Email, new TwoFactorProvider { Enabled = false } }, + { premiumProviderType, new TwoFactorProvider { Enabled = true } } + }; + + foreach (var user in users) + { + user.Premium = false; + user.SetTwoFactorProviders(twoFactorProviders); + } + + // Only the first user has premium access + var premiumStatus = users.ToDictionary( + u => u.Id, + u => users.IndexOf(u) == 0); + + sutProvider.GetDependency() + .GetManyAsync(Arg.Is>(i => i.All(userIds.Contains))) + .Returns(users); + + sutProvider.GetDependency() + .HasPremiumAccessAsync(userIds) + .Returns(premiumStatus); + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledVNextAsync(userIds); + + // Assert + foreach (var user in users) + { + var expectedEnabled = premiumStatus[user.Id]; + Assert.Contains(result, res => res.userId == user.Id && res.twoFactorIsEnabled == expectedEnabled); + } + } + + [Theory] + [BitAutoData("")] + [BitAutoData("{}")] + [BitAutoData((string)null)] + public async Task TwoFactorIsEnabledVNextAsync_WithNullOrEmptyTwoFactorProviders_ReturnsAllTwoFactorDisabled( + string twoFactorProviders, + SutProvider sutProvider, + List users) + { + // Arrange + var userIds = users.Select(u => u.Id).ToList(); + + foreach (var user in users) + { + user.TwoFactorProviders = twoFactorProviders; + } + + var premiumStatus = users.ToDictionary(u => u.Id, u => false); + + sutProvider.GetDependency() + .GetManyAsync(Arg.Is>(i => i.All(userIds.Contains))) + .Returns(users); + + sutProvider.GetDependency() + .HasPremiumAccessAsync(userIds) + .Returns(premiumStatus); + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledVNextAsync(userIds); + + // Assert + foreach (var user in users) + { + Assert.Contains(result, res => res.userId == user.Id && res.twoFactorIsEnabled == false); + } + } + + [Theory] + [BitAutoData] + public async Task TwoFactorIsEnabledVNextAsync_Generic_WithNoUserIds_ReturnsAllTwoFactorDisabled( + SutProvider sutProvider, + List users) + { + // Arrange + foreach (var user in users) + { + user.UserId = null; + } + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledVNextAsync(users); + + // Assert + foreach (var user in users) + { + Assert.Contains(result, res => res.user.Equals(user) && res.twoFactorIsEnabled == false); + } + + // No UserIds were supplied so no calls to the UserRepository should have been made + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .GetManyAsync(default); + } + + [Theory] + [BitAutoData(TwoFactorProviderType.Authenticator)] + [BitAutoData(TwoFactorProviderType.Email)] + [BitAutoData(TwoFactorProviderType.Remember)] + [BitAutoData(TwoFactorProviderType.OrganizationDuo)] + [BitAutoData(TwoFactorProviderType.WebAuthn)] + public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithProviderTypeNotRequiringPremium_ReturnsTrue( + TwoFactorProviderType freeProviderType, + SutProvider sutProvider, + User user) + { + // Arrange + var twoFactorProviders = new Dictionary + { + { freeProviderType, new TwoFactorProvider { Enabled = true } } + }; + + user.Premium = false; + user.SetTwoFactorProviders(twoFactorProviders); + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledVNextAsync(user); + + // Assert + Assert.True(result); + + // Should not need to check premium access for free providers + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .HasPremiumAccessAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithNoTwoFactorEnabled_ReturnsFalse( + SutProvider sutProvider, + User user) + { + // Arrange + var twoFactorProviders = new Dictionary + { + { TwoFactorProviderType.Email, new TwoFactorProvider { Enabled = false } } + }; + + user.SetTwoFactorProviders(twoFactorProviders); + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledVNextAsync(user); + + // Assert + Assert.False(result); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .HasPremiumAccessAsync(Arg.Any()); + } + + [Theory] + [BitAutoData(TwoFactorProviderType.Duo)] + [BitAutoData(TwoFactorProviderType.YubiKey)] + public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithProviderTypeRequiringPremium_WithoutPremium_ReturnsFalse( + TwoFactorProviderType premiumProviderType, + SutProvider sutProvider, + User user) + { + // Arrange + var twoFactorProviders = new Dictionary + { + { premiumProviderType, new TwoFactorProvider { Enabled = true } } + }; + + user.Premium = false; + user.SetTwoFactorProviders(twoFactorProviders); + + sutProvider.GetDependency() + .HasPremiumAccessAsync(user.Id) + .Returns(false); + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledVNextAsync(user); + + // Assert + Assert.False(result); + + await sutProvider.GetDependency() + .Received(1) + .HasPremiumAccessAsync(user.Id); + } + + [Theory] + [BitAutoData(TwoFactorProviderType.Duo)] + [BitAutoData(TwoFactorProviderType.YubiKey)] + public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithProviderTypeRequiringPremium_WithPersonalPremium_ReturnsTrue( + TwoFactorProviderType premiumProviderType, + SutProvider sutProvider, + User user) + { + // Arrange + var twoFactorProviders = new Dictionary + { + { premiumProviderType, new TwoFactorProvider { Enabled = true } } + }; + + user.Premium = true; + user.SetTwoFactorProviders(twoFactorProviders); + + sutProvider.GetDependency() + .HasPremiumAccessAsync(user.Id) + .Returns(true); + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledVNextAsync(user); + + // Assert + Assert.True(result); + + await sutProvider.GetDependency() + .Received(1) + .HasPremiumAccessAsync(user.Id); + } + + [Theory] + [BitAutoData(TwoFactorProviderType.Duo)] + [BitAutoData(TwoFactorProviderType.YubiKey)] + public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithProviderTypeRequiringPremium_WithOrgPremium_ReturnsTrue( + TwoFactorProviderType premiumProviderType, + SutProvider sutProvider, + User user) + { + // Arrange + var twoFactorProviders = new Dictionary + { + { premiumProviderType, new TwoFactorProvider { Enabled = true } } + }; + + user.Premium = false; + user.SetTwoFactorProviders(twoFactorProviders); + + sutProvider.GetDependency() + .HasPremiumAccessAsync(user.Id) + .Returns(true); // Has premium from org + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledVNextAsync(user); + + // Assert + Assert.True(result); + + await sutProvider.GetDependency() + .Received(1) + .HasPremiumAccessAsync(user.Id); + } + + [Theory] + [BitAutoData] + public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithNullTwoFactorProviders_ReturnsFalse( + SutProvider sutProvider, + User user) + { + // Arrange + user.TwoFactorProviders = null; + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledVNextAsync(user); + + // Assert + Assert.False(result); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .HasPremiumAccessAsync(Arg.Any()); + } + private class TestTwoFactorProviderUser : ITwoFactorProvidersUser { public Guid? Id { get; set; } @@ -418,10 +820,5 @@ public Dictionary GetTwoFactorProvider { return Id; } - - public bool GetPremium() - { - return Premium; - } } } diff --git a/test/Core.Test/Billing/Premium/Queries/HasPremiumAccessQueryTests.cs b/test/Core.Test/Billing/Premium/Queries/HasPremiumAccessQueryTests.cs new file mode 100644 index 000000000000..c77a354589cc --- /dev/null +++ b/test/Core.Test/Billing/Premium/Queries/HasPremiumAccessQueryTests.cs @@ -0,0 +1,213 @@ +using Bit.Core.Billing.Premium.Queries; +using Bit.Core.Models.Data; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Billing.Premium.Queries; + +[SutProviderCustomize] +public class HasPremiumAccessQueryTests +{ + [Theory, BitAutoData] + public async Task HasPremiumAccessAsync_WhenUserHasPersonalPremium_ReturnsTrue( + UserWithCalculatedPremium user, + SutProvider sutProvider) + { + // Arrange + user.Premium = true; + user.HasPremiumAccess = true; + + sutProvider.GetDependency() + .GetCalculatedPremiumAsync(user.Id) + .Returns(user); + + // Act + var result = await sutProvider.Sut.HasPremiumAccessAsync(user.Id); + + // Assert + Assert.True(result); + } + + [Theory, BitAutoData] + public async Task HasPremiumAccessAsync_WhenUserHasNoPersonalPremiumButHasOrgPremium_ReturnsTrue( + UserWithCalculatedPremium user, + SutProvider sutProvider) + { + // Arrange + user.Premium = false; + user.HasPremiumAccess = true; // Has org premium + + sutProvider.GetDependency() + .GetCalculatedPremiumAsync(user.Id) + .Returns(user); + + // Act + var result = await sutProvider.Sut.HasPremiumAccessAsync(user.Id); + + // Assert + Assert.True(result); + } + + [Theory, BitAutoData] + public async Task HasPremiumAccessAsync_WhenUserHasNoPersonalPremiumAndNoOrgPremium_ReturnsFalse( + UserWithCalculatedPremium user, + SutProvider sutProvider) + { + // Arrange + user.Premium = false; + user.HasPremiumAccess = false; + + sutProvider.GetDependency() + .GetCalculatedPremiumAsync(user.Id) + .Returns(user); + + // Act + var result = await sutProvider.Sut.HasPremiumAccessAsync(user.Id); + + // Assert + Assert.False(result); + } + + [Theory, BitAutoData] + public async Task HasPremiumAccessAsync_WhenUserNotFound_ReturnsFalse( + Guid userId, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetCalculatedPremiumAsync(userId) + .Returns((UserWithCalculatedPremium)null); + + // Act + var result = await sutProvider.Sut.HasPremiumAccessAsync(userId); + + // Assert + Assert.False(result); + } + + [Theory, BitAutoData] + public async Task HasPremiumFromOrganizationAsync_WhenUserHasNoOrganizations_ReturnsFalse( + UserWithCalculatedPremium user, + SutProvider sutProvider) + { + // Arrange + user.Premium = false; + user.HasPremiumAccess = false; // No premium from anywhere + + sutProvider.GetDependency() + .GetCalculatedPremiumAsync(user.Id) + .Returns(user); + + // Act + var result = await sutProvider.Sut.HasPremiumFromOrganizationAsync(user.Id); + + // Assert + Assert.False(result); + } + + [Theory, BitAutoData] + public async Task HasPremiumFromOrganizationAsync_WhenUserHasPremiumFromOrg_ReturnsTrue( + UserWithCalculatedPremium user, + SutProvider sutProvider) + { + // Arrange + user.Premium = false; // No personal premium + user.HasPremiumAccess = true; // But has premium from org + + sutProvider.GetDependency() + .GetCalculatedPremiumAsync(user.Id) + .Returns(user); + + // Act + var result = await sutProvider.Sut.HasPremiumFromOrganizationAsync(user.Id); + + // Assert + Assert.True(result); + } + + [Theory, BitAutoData] + public async Task HasPremiumFromOrganizationAsync_WhenUserHasOnlyPersonalPremium_ReturnsFalse( + UserWithCalculatedPremium user, + SutProvider sutProvider) + { + // Arrange + user.Premium = true; // Has personal premium + user.HasPremiumAccess = true; + + sutProvider.GetDependency() + .GetCalculatedPremiumAsync(user.Id) + .Returns(user); + + // Act + var result = await sutProvider.Sut.HasPremiumFromOrganizationAsync(user.Id); + + // Assert + Assert.False(result); // Should return false because premium is from personal, not org + } + + [Theory, BitAutoData] + public async Task HasPremiumFromOrganizationAsync_WhenUserNotFound_ReturnsFalse( + Guid userId, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetCalculatedPremiumAsync(userId) + .Returns((UserWithCalculatedPremium)null); + + // Act + var result = await sutProvider.Sut.HasPremiumFromOrganizationAsync(userId); + + // Assert + Assert.False(result); + } + + [Theory, BitAutoData] + public async Task HasPremiumAccessAsync_Bulk_WhenEmptyList_ReturnsEmptyDictionary( + SutProvider sutProvider) + { + // Arrange + var userIds = new List(); + + sutProvider.GetDependency() + .GetManyWithCalculatedPremiumAsync(userIds) + .Returns(new List()); + + // Act + var result = await sutProvider.Sut.HasPremiumAccessAsync(userIds); + + // Assert + Assert.Empty(result); + } + + [Theory, BitAutoData] + public async Task HasPremiumAccessAsync_Bulk_ReturnsCorrectStatus( + List users, + SutProvider sutProvider) + { + // Arrange + users[0].HasPremiumAccess = true; + users[1].HasPremiumAccess = false; + users[2].HasPremiumAccess = true; + + var userIds = users.Select(u => u.Id).ToList(); + + sutProvider.GetDependency() + .GetManyWithCalculatedPremiumAsync(userIds) + .Returns(users); + + // Act + var result = await sutProvider.Sut.HasPremiumAccessAsync(userIds); + + // Assert + Assert.Equal(users.Count, result.Count); + Assert.True(result[users[0].Id]); + Assert.False(result[users[1].Id]); + Assert.True(result[users[2].Id]); + } +} + +