From f83a79073c7410d8e97323198ea4a21e0f7c261d Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Thu, 4 Dec 2025 17:12:36 +0000 Subject: [PATCH 01/16] Add PremiumAccessCacheCheck feature flag to Constants.cs --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index af5b738cd0ab..fc381f9a5b20 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 PremiumAccessCacheCheck = "pm-21411-premium-access-cache-check"; /* Architecture */ public const string DesktopMigrationMilestone1 = "desktop-ui-migration-milestone-1"; From d30f3bdbff2310f994ec72b0e6d8294cf4de1da5 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Thu, 4 Dec 2025 17:13:24 +0000 Subject: [PATCH 02/16] Add IPremiumAccessQuery interface and PremiumAccessQuery implementation for checking user premium access status --- .../PremiumAccess/IPremiumAccessQuery.cs | 50 ++++++++++ .../PremiumAccess/PremiumAccessQuery.cs | 97 +++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 src/Core/Auth/UserFeatures/PremiumAccess/IPremiumAccessQuery.cs create mode 100644 src/Core/Auth/UserFeatures/PremiumAccess/PremiumAccessQuery.cs diff --git a/src/Core/Auth/UserFeatures/PremiumAccess/IPremiumAccessQuery.cs b/src/Core/Auth/UserFeatures/PremiumAccess/IPremiumAccessQuery.cs new file mode 100644 index 000000000000..0f5983158a10 --- /dev/null +++ b/src/Core/Auth/UserFeatures/PremiumAccess/IPremiumAccessQuery.cs @@ -0,0 +1,50 @@ +using Bit.Core.Entities; + +namespace Bit.Core.Auth.UserFeatures.PremiumAccess; + +/// +/// 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 IPremiumAccessQuery +{ + /// + /// 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 to check for premium access + /// True if user can access premium features; false otherwise + Task CanAccessPremiumAsync(User user); + + /// + /// Checks if a user has access to premium features (personal subscription or organization). + /// Use this overload when you already know the personal premium status and only need to check organization premium. + /// + /// The user ID to check for premium access + /// Whether the user has a personal premium subscription + /// True if user can access premium features; false otherwise + Task CanAccessPremiumAsync(Guid userId, bool hasPersonalPremium); + + /// + /// 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 cached organization abilities and minimizes database queries. + /// + /// The users to check for premium access + /// Dictionary mapping user IDs to their premium access status (personal or through organization) + Task> CanAccessPremiumBulkAsync(IEnumerable users); +} + diff --git a/src/Core/Auth/UserFeatures/PremiumAccess/PremiumAccessQuery.cs b/src/Core/Auth/UserFeatures/PremiumAccess/PremiumAccessQuery.cs new file mode 100644 index 000000000000..9dc3045980a6 --- /dev/null +++ b/src/Core/Auth/UserFeatures/PremiumAccess/PremiumAccessQuery.cs @@ -0,0 +1,97 @@ +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Bit.Core.Services; + +namespace Bit.Core.Auth.UserFeatures.PremiumAccess; + +/// +/// Query for checking premium access status for users using cached organization abilities. +/// +public class PremiumAccessQuery : IPremiumAccessQuery +{ + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IApplicationCacheService _applicationCacheService; + + public PremiumAccessQuery( + IOrganizationUserRepository organizationUserRepository, + IApplicationCacheService applicationCacheService) + { + _organizationUserRepository = organizationUserRepository; + _applicationCacheService = applicationCacheService; + } + + public async Task CanAccessPremiumAsync(User user) + { + return await CanAccessPremiumAsync(user.Id, user.Premium); + } + + public async Task CanAccessPremiumAsync(Guid userId, bool hasPersonalPremium) + { + if (hasPersonalPremium) + { + return true; + } + + return await HasPremiumFromOrganizationAsync(userId); + } + + public async Task HasPremiumFromOrganizationAsync(Guid userId) + { + // Note: GetManyByUserAsync only returns Accepted and Confirmed status org users + var orgUsers = await _organizationUserRepository.GetManyByUserAsync(userId); + if (!orgUsers.Any()) + { + return false; + } + + var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); + return orgUsers.Any(ou => + orgAbilities.TryGetValue(ou.OrganizationId, out var orgAbility) && + orgAbility.UsersGetPremium && + orgAbility.Enabled); + } + + public async Task> CanAccessPremiumBulkAsync(IEnumerable users) + { + var result = new Dictionary(); + var usersList = users.ToList(); + + if (!usersList.Any()) + { + return result; + } + + var userIds = usersList.Select(u => u.Id).ToList(); + + // Get all org memberships for these users in one query + // Note: GetManyByManyUsersAsync only returns Accepted and Confirmed status org users + var allOrgUsers = await _organizationUserRepository.GetManyByManyUsersAsync(userIds); + var orgUsersGrouped = allOrgUsers + .Where(ou => ou.UserId.HasValue) + .GroupBy(ou => ou.UserId!.Value) + .ToDictionary(g => g.Key, g => g.ToList()); + + var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); + + foreach (var user in usersList) + { + var hasPersonalPremium = user.Premium; + if (hasPersonalPremium) + { + result[user.Id] = true; + continue; + } + + var hasPremiumFromOrg = orgUsersGrouped.TryGetValue(user.Id, out var userOrgs) && + userOrgs.Any(ou => + orgAbilities.TryGetValue(ou.OrganizationId, out var orgAbility) && + orgAbility.UsersGetPremium && + orgAbility.Enabled); + + result[user.Id] = hasPremiumFromOrg; + } + + return result; + } +} + From 377ae31a99e8c39f00987a04aa82372c68cd2472 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Thu, 4 Dec 2025 17:13:56 +0000 Subject: [PATCH 03/16] Add unit tests for PremiumAccessQuery to validate user premium access logic --- .../PremiumAccess/PremiumAccessQueryTests.cs | 701 ++++++++++++++++++ 1 file changed, 701 insertions(+) create mode 100644 test/Core.Test/Auth/UserFeatures/PremiumAccess/PremiumAccessQueryTests.cs diff --git a/test/Core.Test/Auth/UserFeatures/PremiumAccess/PremiumAccessQueryTests.cs b/test/Core.Test/Auth/UserFeatures/PremiumAccess/PremiumAccessQueryTests.cs new file mode 100644 index 000000000000..26d73160b0da --- /dev/null +++ b/test/Core.Test/Auth/UserFeatures/PremiumAccess/PremiumAccessQueryTests.cs @@ -0,0 +1,701 @@ +using Bit.Core.Auth.UserFeatures.PremiumAccess; +using Bit.Core.Entities; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Auth.UserFeatures.PremiumAccess; + +[SutProviderCustomize] +public class PremiumAccessQueryTests +{ + [Theory, BitAutoData] + public async Task CanAccessPremiumAsync_WithUser_WhenUserHasPersonalPremium_ReturnsTrue( + User user, + SutProvider sutProvider) + { + // Arrange + user.Premium = true; + + // Act + var result = await sutProvider.Sut.CanAccessPremiumAsync(user); + + // Assert + Assert.True(result); + + // Should not call repository since personal premium is enough + await sutProvider.GetDependency() + .DidNotReceive() + .GetManyByUserAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task CanAccessPremiumAsync_WithUser_WhenUserHasNoPersonalPremiumButHasOrgPremium_ReturnsTrue( + User user, + OrganizationUser orgUser, + SutProvider sutProvider) + { + // Arrange + user.Premium = false; + orgUser.UserId = user.Id; + + var orgAbilities = new Dictionary + { + { + orgUser.OrganizationId, new OrganizationAbility + { + Id = orgUser.OrganizationId, + UsersGetPremium = true, + Enabled = true + } + } + }; + + sutProvider.GetDependency() + .GetManyByUserAsync(user.Id) + .Returns(new List { orgUser }); + + sutProvider.GetDependency() + .GetOrganizationAbilitiesAsync() + .Returns(orgAbilities); + + // Act + var result = await sutProvider.Sut.CanAccessPremiumAsync(user); + + // Assert + Assert.True(result); + } + + [Theory, BitAutoData] + public async Task CanAccessPremiumAsync_WithUser_WhenUserHasNoPersonalPremiumAndNoOrgPremium_ReturnsFalse( + User user, + SutProvider sutProvider) + { + // Arrange + user.Premium = false; + + sutProvider.GetDependency() + .GetManyByUserAsync(user.Id) + .Returns(new List()); + + sutProvider.GetDependency() + .GetOrganizationAbilitiesAsync() + .Returns(new Dictionary()); + + // Act + var result = await sutProvider.Sut.CanAccessPremiumAsync(user); + + // Assert + Assert.False(result); + } + + [Theory, BitAutoData] + public async Task CanAccessPremiumAsync_WithGuidAndPremiumFlag_WhenHasPersonalPremium_ReturnsTrue( + Guid userId, + SutProvider sutProvider) + { + // Act + var result = await sutProvider.Sut.CanAccessPremiumAsync(userId, hasPersonalPremium: true); + + // Assert + Assert.True(result); + + // Should not call repository since personal premium is enough + await sutProvider.GetDependency() + .DidNotReceive() + .GetManyByUserAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task CanAccessPremiumAsync_WithGuidAndPremiumFlag_WhenNoPersonalPremiumButHasOrgPremium_ReturnsTrue( + Guid userId, + OrganizationUser orgUser, + SutProvider sutProvider) + { + // Arrange + orgUser.UserId = userId; + + var orgAbilities = new Dictionary + { + { + orgUser.OrganizationId, new OrganizationAbility + { + Id = orgUser.OrganizationId, + UsersGetPremium = true, + Enabled = true + } + } + }; + + sutProvider.GetDependency() + .GetManyByUserAsync(userId) + .Returns(new List { orgUser }); + + sutProvider.GetDependency() + .GetOrganizationAbilitiesAsync() + .Returns(orgAbilities); + + // Act + var result = await sutProvider.Sut.CanAccessPremiumAsync(userId, hasPersonalPremium: false); + + // Assert + Assert.True(result); + } + + [Theory, BitAutoData] + public async Task CanAccessPremiumAsync_WithGuidAndPremiumFlag_WhenNoPersonalPremiumAndNoOrgPremium_ReturnsFalse( + Guid userId, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetManyByUserAsync(userId) + .Returns(new List()); + + sutProvider.GetDependency() + .GetOrganizationAbilitiesAsync() + .Returns(new Dictionary()); + + // Act + var result = await sutProvider.Sut.CanAccessPremiumAsync(userId, hasPersonalPremium: false); + + // Assert + Assert.False(result); + } + + [Theory, BitAutoData] + public async Task HasPremiumFromOrganizationAsync_WhenUserHasNoOrganizations_ReturnsFalse( + Guid userId, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetManyByUserAsync(userId) + .Returns(new List()); + + // Act + var result = await sutProvider.Sut.HasPremiumFromOrganizationAsync(userId); + + // Assert + Assert.False(result); + + // Should not call cache service if user has no organizations + await sutProvider.GetDependency() + .DidNotReceive() + .GetOrganizationAbilitiesAsync(); + } + + [Theory, BitAutoData] + public async Task HasPremiumFromOrganizationAsync_WhenOrgHasPremiumAndEnabled_ReturnsTrue( + Guid userId, + OrganizationUser orgUser, + SutProvider sutProvider) + { + // Arrange + orgUser.UserId = userId; + + var orgAbilities = new Dictionary + { + { + orgUser.OrganizationId, new OrganizationAbility + { + Id = orgUser.OrganizationId, + UsersGetPremium = true, + Enabled = true + } + } + }; + + sutProvider.GetDependency() + .GetManyByUserAsync(userId) + .Returns(new List { orgUser }); + + sutProvider.GetDependency() + .GetOrganizationAbilitiesAsync() + .Returns(orgAbilities); + + // Act + var result = await sutProvider.Sut.HasPremiumFromOrganizationAsync(userId); + + // Assert + Assert.True(result); + } + + [Theory, BitAutoData] + public async Task HasPremiumFromOrganizationAsync_WhenOrgDoesNotHaveUsersGetPremium_ReturnsFalse( + Guid userId, + OrganizationUser orgUser, + SutProvider sutProvider) + { + // Arrange + orgUser.UserId = userId; + + var orgAbilities = new Dictionary + { + { + orgUser.OrganizationId, new OrganizationAbility + { + Id = orgUser.OrganizationId, + UsersGetPremium = false, // No premium for users + Enabled = true + } + } + }; + + sutProvider.GetDependency() + .GetManyByUserAsync(userId) + .Returns(new List { orgUser }); + + sutProvider.GetDependency() + .GetOrganizationAbilitiesAsync() + .Returns(orgAbilities); + + // Act + var result = await sutProvider.Sut.HasPremiumFromOrganizationAsync(userId); + + // Assert + Assert.False(result); + } + + [Theory, BitAutoData] + public async Task HasPremiumFromOrganizationAsync_WhenOrgIsDisabled_ReturnsFalse( + Guid userId, + OrganizationUser orgUser, + SutProvider sutProvider) + { + // Arrange + orgUser.UserId = userId; + + var orgAbilities = new Dictionary + { + { + orgUser.OrganizationId, new OrganizationAbility + { + Id = orgUser.OrganizationId, + UsersGetPremium = true, + Enabled = false // Organization disabled + } + } + }; + + sutProvider.GetDependency() + .GetManyByUserAsync(userId) + .Returns(new List { orgUser }); + + sutProvider.GetDependency() + .GetOrganizationAbilitiesAsync() + .Returns(orgAbilities); + + // Act + var result = await sutProvider.Sut.HasPremiumFromOrganizationAsync(userId); + + // Assert + Assert.False(result); + } + + [Theory, BitAutoData] + public async Task HasPremiumFromOrganizationAsync_WhenOrgNotInCache_ReturnsFalse( + Guid userId, + OrganizationUser orgUser, + SutProvider sutProvider) + { + // Arrange + orgUser.UserId = userId; + + sutProvider.GetDependency() + .GetManyByUserAsync(userId) + .Returns(new List { orgUser }); + + sutProvider.GetDependency() + .GetOrganizationAbilitiesAsync() + .Returns(new Dictionary()); // Empty cache + + // Act + var result = await sutProvider.Sut.HasPremiumFromOrganizationAsync(userId); + + // Assert + Assert.False(result); + } + + [Theory, BitAutoData] + public async Task HasPremiumFromOrganizationAsync_WhenUserInMultipleOrgs_OnlyOneHasPremium_ReturnsTrue( + Guid userId, + OrganizationUser orgUser1, + OrganizationUser orgUser2, + OrganizationUser orgUser3, + SutProvider sutProvider) + { + // Arrange + orgUser1.UserId = userId; + orgUser2.UserId = userId; + orgUser3.UserId = userId; + + var orgAbilities = new Dictionary + { + { + orgUser1.OrganizationId, new OrganizationAbility + { + Id = orgUser1.OrganizationId, + UsersGetPremium = false, + Enabled = true + } + }, + { + orgUser2.OrganizationId, new OrganizationAbility + { + Id = orgUser2.OrganizationId, + UsersGetPremium = true, // This one has premium + Enabled = true + } + }, + { + orgUser3.OrganizationId, new OrganizationAbility + { + Id = orgUser3.OrganizationId, + UsersGetPremium = false, + Enabled = true + } + } + }; + + sutProvider.GetDependency() + .GetManyByUserAsync(userId) + .Returns(new List { orgUser1, orgUser2, orgUser3 }); + + sutProvider.GetDependency() + .GetOrganizationAbilitiesAsync() + .Returns(orgAbilities); + + // Act + var result = await sutProvider.Sut.HasPremiumFromOrganizationAsync(userId); + + // Assert + Assert.True(result); + } + + [Theory, BitAutoData] + public async Task CanAccessPremiumBulkAsync_WhenEmptyUsersList_ReturnsEmptyDictionary( + SutProvider sutProvider) + { + // Arrange + var users = new List(); + + // Act + var result = await sutProvider.Sut.CanAccessPremiumBulkAsync(users); + + // Assert + Assert.Empty(result); + + // Should not call dependencies for empty list + await sutProvider.GetDependency() + .DidNotReceive() + .GetManyByManyUsersAsync(Arg.Any>()); + } + + [Theory, BitAutoData] + public async Task CanAccessPremiumBulkAsync_WhenAllUsersHavePersonalPremium_ReturnsAllTrue( + List users, + SutProvider sutProvider) + { + // Arrange + foreach (var user in users) + { + user.Premium = true; + } + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns(new List()); + + sutProvider.GetDependency() + .GetOrganizationAbilitiesAsync() + .Returns(new Dictionary()); + + // Act + var result = await sutProvider.Sut.CanAccessPremiumBulkAsync(users); + + // Assert + Assert.Equal(users.Count, result.Count); + foreach (var user in users) + { + Assert.True(result[user.Id]); + } + } + + [Theory, BitAutoData] + public async Task CanAccessPremiumBulkAsync_WhenNoUsersHavePremium_ReturnsAllFalse( + List users, + SutProvider sutProvider) + { + // Arrange + foreach (var user in users) + { + user.Premium = false; + } + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns(new List()); + + sutProvider.GetDependency() + .GetOrganizationAbilitiesAsync() + .Returns(new Dictionary()); + + // Act + var result = await sutProvider.Sut.CanAccessPremiumBulkAsync(users); + + // Assert + Assert.Equal(users.Count, result.Count); + foreach (var user in users) + { + Assert.False(result[user.Id]); + } + } + + [Theory, BitAutoData] + public async Task CanAccessPremiumBulkAsync_WhenSomeUsersHaveOrgPremium_ReturnsCorrectStatus( + User user1, + User user2, + User user3, + OrganizationUser orgUser1, + OrganizationUser orgUser2, + Guid orgId, + SutProvider sutProvider) + { + // Arrange + user1.Premium = false; // Will get premium from org + user2.Premium = true; // Has personal premium + user3.Premium = false; // No premium at all + + orgUser1.UserId = user1.Id; + orgUser1.OrganizationId = orgId; + orgUser2.UserId = user3.Id; + orgUser2.OrganizationId = orgId; + + var users = new List { user1, user2, user3 }; + var orgUsers = new List { orgUser1, orgUser2 }; + + var orgAbilities = new Dictionary + { + { + orgId, new OrganizationAbility + { + Id = orgId, + UsersGetPremium = true, + Enabled = true + } + } + }; + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Is>(ids => + ids.Contains(user1.Id) && ids.Contains(user2.Id) && ids.Contains(user3.Id))) + .Returns(orgUsers); + + sutProvider.GetDependency() + .GetOrganizationAbilitiesAsync() + .Returns(orgAbilities); + + // Act + var result = await sutProvider.Sut.CanAccessPremiumBulkAsync(users); + + // Assert + Assert.Equal(3, result.Count); + Assert.True(result[user1.Id]); // Premium from org + Assert.True(result[user2.Id]); // Personal premium + Assert.True(result[user3.Id]); // Premium from org + } + + [Theory, BitAutoData] + public async Task CanAccessPremiumBulkAsync_WhenOrgUsersHaveNoUserId_FiltersThemOut( + User user1, + OrganizationUser orgUser1, + OrganizationUser orgUser2, + Guid orgId, + SutProvider sutProvider) + { + // Arrange + user1.Premium = false; + + orgUser1.UserId = user1.Id; + orgUser1.OrganizationId = orgId; + orgUser2.UserId = null; // This should be filtered out + orgUser2.OrganizationId = orgId; + + var users = new List { user1 }; + var orgUsers = new List { orgUser1, orgUser2 }; + + var orgAbilities = new Dictionary + { + { + orgId, new OrganizationAbility + { + Id = orgId, + UsersGetPremium = true, + Enabled = true + } + } + }; + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns(orgUsers); + + sutProvider.GetDependency() + .GetOrganizationAbilitiesAsync() + .Returns(orgAbilities); + + // Act + var result = await sutProvider.Sut.CanAccessPremiumBulkAsync(users); + + // Assert + Assert.Single(result); + Assert.True(result[user1.Id]); + } + + [Theory, BitAutoData] + public async Task CanAccessPremiumBulkAsync_WhenOrgIsDisabled_DoesNotGrantPremium( + User user1, + OrganizationUser orgUser1, + Guid orgId, + SutProvider sutProvider) + { + // Arrange + user1.Premium = false; + orgUser1.UserId = user1.Id; + orgUser1.OrganizationId = orgId; + + var users = new List { user1 }; + var orgUsers = new List { orgUser1 }; + + var orgAbilities = new Dictionary + { + { + orgId, new OrganizationAbility + { + Id = orgId, + UsersGetPremium = true, + Enabled = false // Organization disabled + } + } + }; + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns(orgUsers); + + sutProvider.GetDependency() + .GetOrganizationAbilitiesAsync() + .Returns(orgAbilities); + + // Act + var result = await sutProvider.Sut.CanAccessPremiumBulkAsync(users); + + // Assert + Assert.Single(result); + Assert.False(result[user1.Id]); + } + + [Theory, BitAutoData] + public async Task CanAccessPremiumBulkAsync_WhenOrgDoesNotHaveUsersGetPremium_DoesNotGrantPremium( + User user1, + OrganizationUser orgUser1, + Guid orgId, + SutProvider sutProvider) + { + // Arrange + user1.Premium = false; + orgUser1.UserId = user1.Id; + orgUser1.OrganizationId = orgId; + + var users = new List { user1 }; + var orgUsers = new List { orgUser1 }; + + var orgAbilities = new Dictionary + { + { + orgId, new OrganizationAbility + { + Id = orgId, + UsersGetPremium = false, // Premium not available for users + Enabled = true + } + } + }; + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns(orgUsers); + + sutProvider.GetDependency() + .GetOrganizationAbilitiesAsync() + .Returns(orgAbilities); + + // Act + var result = await sutProvider.Sut.CanAccessPremiumBulkAsync(users); + + // Assert + Assert.Single(result); + Assert.False(result[user1.Id]); + } + + [Theory, BitAutoData] + public async Task CanAccessPremiumBulkAsync_WhenUserInMultipleOrgs_OnlyOneHasPremium_GrantsPremium( + User user1, + OrganizationUser orgUser1, + OrganizationUser orgUser2, + Guid orgId1, + Guid orgId2, + SutProvider sutProvider) + { + // Arrange + user1.Premium = false; + + orgUser1.UserId = user1.Id; + orgUser1.OrganizationId = orgId1; + orgUser2.UserId = user1.Id; + orgUser2.OrganizationId = orgId2; + + var users = new List { user1 }; + var orgUsers = new List { orgUser1, orgUser2 }; + + var orgAbilities = new Dictionary + { + { + orgId1, new OrganizationAbility + { + Id = orgId1, + UsersGetPremium = false, + Enabled = true + } + }, + { + orgId2, new OrganizationAbility + { + Id = orgId2, + UsersGetPremium = true, // This one grants premium + Enabled = true + } + } + }; + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns(orgUsers); + + sutProvider.GetDependency() + .GetOrganizationAbilitiesAsync() + .Returns(orgAbilities); + + // Act + var result = await sutProvider.Sut.CanAccessPremiumBulkAsync(users); + + // Assert + Assert.Single(result); + Assert.True(result[user1.Id]); + } +} From 683a6cb1eef5ad96b16c476e8d73e6e495591ba6 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Thu, 4 Dec 2025 17:15:04 +0000 Subject: [PATCH 04/16] Add XML documentation to Premium in OrganizationUserUserDetails and User classes --- .../OrganizationUsers/OrganizationUserUserDetails.cs | 4 ++++ src/Core/Entities/User.cs | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs index 00bac01f7609..a760f8d8e327 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs @@ -20,6 +20,10 @@ public class OrganizationUserUserDetails : IExternal, ITwoFactorProvidersUser, I public string Email { get; set; } public string AvatarColor { get; set; } public string TwoFactorProviders { get; set; } + /// + /// User's personal premium subscription status. Does not reflect organization premium access. + /// 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/Entities/User.cs b/src/Core/Entities/User.cs index 1ca660677914..d77cb9c52491 100644 --- a/src/Core/Entities/User.cs +++ b/src/Core/Entities/User.cs @@ -69,6 +69,10 @@ 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. + /// public bool Premium { get; set; } public DateTime? PremiumExpirationDate { get; set; } public DateTime? RenewalReminderDate { get; set; } From 08e14134bb54761133fb998e4333185c437b03d7 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Thu, 4 Dec 2025 17:17:44 +0000 Subject: [PATCH 05/16] Add PremiumAccessQueries to UserServiceCollectionExtensions --- .../Auth/UserFeatures/UserServiceCollectionExtensions.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs index 7c50f7f17be3..fee2cb1b0428 100644 --- a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs +++ b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using Bit.Core.Auth.Sso; using Bit.Core.Auth.UserFeatures.DeviceTrust; +using Bit.Core.Auth.UserFeatures.PremiumAccess; using Bit.Core.Auth.UserFeatures.Registration; using Bit.Core.Auth.UserFeatures.Registration.Implementations; using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces; @@ -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(); From e494a105988e12bf5ee957dd9fbab42b0e9b4f4d Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Thu, 4 Dec 2025 17:18:15 +0000 Subject: [PATCH 06/16] Refactor TwoFactorIsEnabledQuery to incorporate PremiumAccessQuery and feature flag for premium access checks. Enhanced user premium status retrieval logic and improved handling of user details based on feature flag state. --- .../TwoFactorAuth/TwoFactorIsEnabledQuery.cs | 99 +++++++++++++++---- 1 file changed, 82 insertions(+), 17 deletions(-) diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs b/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs index cc86d3d71d39..abce6bf1ff38 100644 --- a/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs +++ b/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs @@ -3,14 +3,30 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; +using Bit.Core.Auth.UserFeatures.PremiumAccess; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Entities; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; +using Bit.Core.Services; 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 IPremiumAccessQuery _premiumAccessQuery; + private readonly IFeatureService _featureService; + + public TwoFactorIsEnabledQuery( + IUserRepository userRepository, + IPremiumAccessQuery premiumAccessQuery, + IFeatureService featureService) + { + _userRepository = userRepository; + _premiumAccessQuery = premiumAccessQuery; + _featureService = featureService; + } public async Task> TwoFactorIsEnabledAsync(IEnumerable userIds) { @@ -20,15 +36,34 @@ public class TwoFactorIsEnabledQuery(IUserRepository userRepository) : ITwoFacto return result; } - var userDetails = await _userRepository.GetManyWithCalculatedPremiumAsync([.. userIds]); - foreach (var userDetail in userDetails) + if (_featureService.IsEnabled(FeatureFlagKeys.PremiumAccessCacheCheck)) { - result.Add( - (userDetail.Id, - await TwoFactorEnabledAsync(userDetail.GetTwoFactorProviders(), - () => Task.FromResult(userDetail.HasPremiumAccess)) - ) - ); + var users = await _userRepository.GetManyAsync([.. userIds]); + var premiumStatus = await _premiumAccessQuery.CanAccessPremiumBulkAsync(users); + + foreach (var user in users) + { + result.Add( + (user.Id, + await TwoFactorEnabledAsync( + user.GetTwoFactorProviders(), + () => Task.FromResult(premiumStatus.GetValueOrDefault(user.Id, false)) + )) + ); + } + } + else + { + var userDetails = await _userRepository.GetManyWithCalculatedPremiumAsync([.. userIds]); + foreach (var userDetail in userDetails) + { + result.Add( + (userDetail.Id, + await TwoFactorEnabledAsync(userDetail.GetTwoFactorProviders(), + () => Task.FromResult(userDetail.HasPremiumAccess)) + ) + ); + } } return result; @@ -71,13 +106,43 @@ public async Task TwoFactorIsEnabledAsync(ITwoFactorProvidersUser user) return false; } - return await TwoFactorEnabledAsync( - user.GetTwoFactorProviders(), - async () => - { - var calcUser = await _userRepository.GetCalculatedPremiumAsync(userId.Value); - return calcUser?.HasPremiumAccess ?? false; - }); + if (_featureService.IsEnabled(FeatureFlagKeys.PremiumAccessCacheCheck)) + { + // Try to get premium status without fetching User entity if possible + bool hasPersonalPremium; + if (user is User userEntity) + { + hasPersonalPremium = userEntity.Premium; + } + else if (user is OrganizationUserUserDetails orgUserDetails) + { + hasPersonalPremium = orgUserDetails.Premium.GetValueOrDefault(false); + } + else + { + // Fallback: fetch the User entity + var fetchedUser = await _userRepository.GetByIdAsync(userId.Value); + if (fetchedUser == null) + { + return false; + } + hasPersonalPremium = fetchedUser.Premium; + } + + return await TwoFactorEnabledAsync( + user.GetTwoFactorProviders(), + async () => await _premiumAccessQuery.CanAccessPremiumAsync(userId.Value, hasPersonalPremium)); + } + else + { + return await TwoFactorEnabledAsync( + user.GetTwoFactorProviders(), + async () => + { + var calcUser = await _userRepository.GetCalculatedPremiumAsync(userId.Value); + return calcUser?.HasPremiumAccess ?? false; + }); + } } /// From 0cdadd18da8eb1c1862fdf605ab6798aaa408342 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Thu, 4 Dec 2025 17:19:03 +0000 Subject: [PATCH 07/16] Mark methods in IUserRepository and IUserService as obsolete, directing users to new methods in IPremiumAccessQuery for premium access checks. --- src/Core/Repositories/IUserRepository.cs | 2 ++ src/Core/Services/IUserService.cs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/Core/Repositories/IUserRepository.cs b/src/Core/Repositories/IUserRepository.cs index 22effb4329d5..8badc7f402c9 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 'PremiumAccessCacheCheck' 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 'PremiumAccessCacheCheck' 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..6ff367b3e66f 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 IPremiumAccessQuery.CanAccessPremiumAsync instead. This method is only used when feature flag 'PremiumAccessCacheCheck' is disabled.")] Task CanAccessPremium(User user); /// @@ -74,6 +75,7 @@ Task GenerateLicenseAsync(User user, SubscriptionInfo subscriptionI /// /// /// + [Obsolete("Use IPremiumAccessQuery.HasPremiumFromOrganizationAsync instead. This method is only used when feature flag 'PremiumAccessCacheCheck' is disabled.")] Task HasPremiumFromOrganization(User user); Task GenerateSignInTokenAsync(User user, string purpose); From 192052bd6045163a643df32fb781539ad7f094c0 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Fri, 5 Dec 2025 14:43:13 +0000 Subject: [PATCH 08/16] Rename CanAccessPremiumBulkAsync to CanAccessPremiumAsync in IPremiumAccessQuery --- .../PremiumAccess/IPremiumAccessQuery.cs | 2 +- .../PremiumAccess/PremiumAccessQuery.cs | 2 +- .../PremiumAccess/PremiumAccessQueryTests.cs | 32 +++++++++---------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/Core/Auth/UserFeatures/PremiumAccess/IPremiumAccessQuery.cs b/src/Core/Auth/UserFeatures/PremiumAccess/IPremiumAccessQuery.cs index 0f5983158a10..ebd7aec40246 100644 --- a/src/Core/Auth/UserFeatures/PremiumAccess/IPremiumAccessQuery.cs +++ b/src/Core/Auth/UserFeatures/PremiumAccess/IPremiumAccessQuery.cs @@ -45,6 +45,6 @@ public interface IPremiumAccessQuery /// /// The users to check for premium access /// Dictionary mapping user IDs to their premium access status (personal or through organization) - Task> CanAccessPremiumBulkAsync(IEnumerable users); + Task> CanAccessPremiumAsync(IEnumerable users); } diff --git a/src/Core/Auth/UserFeatures/PremiumAccess/PremiumAccessQuery.cs b/src/Core/Auth/UserFeatures/PremiumAccess/PremiumAccessQuery.cs index 9dc3045980a6..8da0f120e08a 100644 --- a/src/Core/Auth/UserFeatures/PremiumAccess/PremiumAccessQuery.cs +++ b/src/Core/Auth/UserFeatures/PremiumAccess/PremiumAccessQuery.cs @@ -51,7 +51,7 @@ public async Task HasPremiumFromOrganizationAsync(Guid userId) orgAbility.Enabled); } - public async Task> CanAccessPremiumBulkAsync(IEnumerable users) + public async Task> CanAccessPremiumAsync(IEnumerable users) { var result = new Dictionary(); var usersList = users.ToList(); diff --git a/test/Core.Test/Auth/UserFeatures/PremiumAccess/PremiumAccessQueryTests.cs b/test/Core.Test/Auth/UserFeatures/PremiumAccess/PremiumAccessQueryTests.cs index 26d73160b0da..b30fec46e6fc 100644 --- a/test/Core.Test/Auth/UserFeatures/PremiumAccess/PremiumAccessQueryTests.cs +++ b/test/Core.Test/Auth/UserFeatures/PremiumAccess/PremiumAccessQueryTests.cs @@ -378,14 +378,14 @@ public async Task HasPremiumFromOrganizationAsync_WhenUserInMultipleOrgs_OnlyOne } [Theory, BitAutoData] - public async Task CanAccessPremiumBulkAsync_WhenEmptyUsersList_ReturnsEmptyDictionary( + public async Task CanAccessPremiumAsync_WhenEmptyUsersList_ReturnsEmptyDictionary( SutProvider sutProvider) { // Arrange var users = new List(); // Act - var result = await sutProvider.Sut.CanAccessPremiumBulkAsync(users); + var result = await sutProvider.Sut.CanAccessPremiumAsync(users); // Assert Assert.Empty(result); @@ -397,7 +397,7 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task CanAccessPremiumBulkAsync_WhenAllUsersHavePersonalPremium_ReturnsAllTrue( + public async Task CanAccessPremiumAsync_WhenAllUsersHavePersonalPremium_ReturnsAllTrue( List users, SutProvider sutProvider) { @@ -416,7 +416,7 @@ public async Task CanAccessPremiumBulkAsync_WhenAllUsersHavePersonalPremium_Retu .Returns(new Dictionary()); // Act - var result = await sutProvider.Sut.CanAccessPremiumBulkAsync(users); + var result = await sutProvider.Sut.CanAccessPremiumAsync(users); // Assert Assert.Equal(users.Count, result.Count); @@ -427,7 +427,7 @@ public async Task CanAccessPremiumBulkAsync_WhenAllUsersHavePersonalPremium_Retu } [Theory, BitAutoData] - public async Task CanAccessPremiumBulkAsync_WhenNoUsersHavePremium_ReturnsAllFalse( + public async Task CanAccessPremiumAsync_WhenNoUsersHavePremium_ReturnsAllFalse( List users, SutProvider sutProvider) { @@ -446,7 +446,7 @@ public async Task CanAccessPremiumBulkAsync_WhenNoUsersHavePremium_ReturnsAllFal .Returns(new Dictionary()); // Act - var result = await sutProvider.Sut.CanAccessPremiumBulkAsync(users); + var result = await sutProvider.Sut.CanAccessPremiumAsync(users); // Assert Assert.Equal(users.Count, result.Count); @@ -457,7 +457,7 @@ public async Task CanAccessPremiumBulkAsync_WhenNoUsersHavePremium_ReturnsAllFal } [Theory, BitAutoData] - public async Task CanAccessPremiumBulkAsync_WhenSomeUsersHaveOrgPremium_ReturnsCorrectStatus( + public async Task CanAccessPremiumAsync_WhenSomeUsersHaveOrgPremium_ReturnsCorrectStatus( User user1, User user2, User user3, @@ -501,7 +501,7 @@ public async Task CanAccessPremiumBulkAsync_WhenSomeUsersHaveOrgPremium_ReturnsC .Returns(orgAbilities); // Act - var result = await sutProvider.Sut.CanAccessPremiumBulkAsync(users); + var result = await sutProvider.Sut.CanAccessPremiumAsync(users); // Assert Assert.Equal(3, result.Count); @@ -511,7 +511,7 @@ public async Task CanAccessPremiumBulkAsync_WhenSomeUsersHaveOrgPremium_ReturnsC } [Theory, BitAutoData] - public async Task CanAccessPremiumBulkAsync_WhenOrgUsersHaveNoUserId_FiltersThemOut( + public async Task CanAccessPremiumAsync_WhenOrgUsersHaveNoUserId_FiltersThemOut( User user1, OrganizationUser orgUser1, OrganizationUser orgUser2, @@ -550,7 +550,7 @@ public async Task CanAccessPremiumBulkAsync_WhenOrgUsersHaveNoUserId_FiltersThem .Returns(orgAbilities); // Act - var result = await sutProvider.Sut.CanAccessPremiumBulkAsync(users); + var result = await sutProvider.Sut.CanAccessPremiumAsync(users); // Assert Assert.Single(result); @@ -558,7 +558,7 @@ public async Task CanAccessPremiumBulkAsync_WhenOrgUsersHaveNoUserId_FiltersThem } [Theory, BitAutoData] - public async Task CanAccessPremiumBulkAsync_WhenOrgIsDisabled_DoesNotGrantPremium( + public async Task CanAccessPremiumAsync_WhenOrgIsDisabled_DoesNotGrantPremium( User user1, OrganizationUser orgUser1, Guid orgId, @@ -593,7 +593,7 @@ public async Task CanAccessPremiumBulkAsync_WhenOrgIsDisabled_DoesNotGrantPremiu .Returns(orgAbilities); // Act - var result = await sutProvider.Sut.CanAccessPremiumBulkAsync(users); + var result = await sutProvider.Sut.CanAccessPremiumAsync(users); // Assert Assert.Single(result); @@ -601,7 +601,7 @@ public async Task CanAccessPremiumBulkAsync_WhenOrgIsDisabled_DoesNotGrantPremiu } [Theory, BitAutoData] - public async Task CanAccessPremiumBulkAsync_WhenOrgDoesNotHaveUsersGetPremium_DoesNotGrantPremium( + public async Task CanAccessPremiumAsync_WhenOrgDoesNotHaveUsersGetPremium_DoesNotGrantPremium( User user1, OrganizationUser orgUser1, Guid orgId, @@ -636,7 +636,7 @@ public async Task CanAccessPremiumBulkAsync_WhenOrgDoesNotHaveUsersGetPremium_Do .Returns(orgAbilities); // Act - var result = await sutProvider.Sut.CanAccessPremiumBulkAsync(users); + var result = await sutProvider.Sut.CanAccessPremiumAsync(users); // Assert Assert.Single(result); @@ -644,7 +644,7 @@ public async Task CanAccessPremiumBulkAsync_WhenOrgDoesNotHaveUsersGetPremium_Do } [Theory, BitAutoData] - public async Task CanAccessPremiumBulkAsync_WhenUserInMultipleOrgs_OnlyOneHasPremium_GrantsPremium( + public async Task CanAccessPremiumAsync_WhenUserInMultipleOrgs_OnlyOneHasPremium_GrantsPremium( User user1, OrganizationUser orgUser1, OrganizationUser orgUser2, @@ -692,7 +692,7 @@ public async Task CanAccessPremiumBulkAsync_WhenUserInMultipleOrgs_OnlyOneHasPre .Returns(orgAbilities); // Act - var result = await sutProvider.Sut.CanAccessPremiumBulkAsync(users); + var result = await sutProvider.Sut.CanAccessPremiumAsync(users); // Assert Assert.Single(result); From 2d312376880cf8cf80cdbf1b01923c43bc364b42 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Fri, 5 Dec 2025 14:44:21 +0000 Subject: [PATCH 09/16] Update TwoFactorIsEnabledQuery to use CanAccessPremiumAsync for premium status checks --- .../Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs b/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs index abce6bf1ff38..d7651648925f 100644 --- a/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs +++ b/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs @@ -39,7 +39,7 @@ public TwoFactorIsEnabledQuery( if (_featureService.IsEnabled(FeatureFlagKeys.PremiumAccessCacheCheck)) { var users = await _userRepository.GetManyAsync([.. userIds]); - var premiumStatus = await _premiumAccessQuery.CanAccessPremiumBulkAsync(users); + var premiumStatus = await _premiumAccessQuery.CanAccessPremiumAsync(users); foreach (var user in users) { From 123f57926fee1ff5ca70c86be5296553a0aba20b Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Fri, 5 Dec 2025 15:01:36 +0000 Subject: [PATCH 10/16] Refactor TwoFactorIsEnabledQuery to introduce VNextAsync methods for improved premium access checks and user detail handling. Removed obsolete feature service dependency and enhanced test coverage for new functionality. --- .../Interfaces/ITwoFactorIsEnabledQuery.cs | 21 + .../TwoFactorAuth/TwoFactorIsEnabledQuery.cs | 152 ++++-- .../TwoFactorIsEnabledQueryTests.cs | 503 +++++++++++++++++- 3 files changed, 613 insertions(+), 63 deletions(-) diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/Interfaces/ITwoFactorIsEnabledQuery.cs b/src/Core/Auth/UserFeatures/TwoFactorAuth/Interfaces/ITwoFactorIsEnabledQuery.cs index 697c10690cd9..9beb658a32c8 100644 --- a/src/Core/Auth/UserFeatures/TwoFactorAuth/Interfaces/ITwoFactorIsEnabledQuery.cs +++ b/src/Core/Auth/UserFeatures/TwoFactorAuth/Interfaces/ITwoFactorIsEnabledQuery.cs @@ -22,4 +22,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 PremiumAccessQuery 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 PremiumAccessQuery 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 PremiumAccessQuery with cached organization abilities for better performance. + /// + /// The user to check. + Task TwoFactorIsEnabledVNextAsync(ITwoFactorProvidersUser user); } diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs b/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs index d7651648925f..9ed022184f0a 100644 --- a/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs +++ b/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs @@ -8,7 +8,6 @@ using Bit.Core.Entities; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; -using Bit.Core.Services; namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth; @@ -16,16 +15,13 @@ public class TwoFactorIsEnabledQuery : ITwoFactorIsEnabledQuery { private readonly IUserRepository _userRepository; private readonly IPremiumAccessQuery _premiumAccessQuery; - private readonly IFeatureService _featureService; public TwoFactorIsEnabledQuery( IUserRepository userRepository, - IPremiumAccessQuery premiumAccessQuery, - IFeatureService featureService) + IPremiumAccessQuery premiumAccessQuery) { _userRepository = userRepository; _premiumAccessQuery = premiumAccessQuery; - _featureService = featureService; } public async Task> TwoFactorIsEnabledAsync(IEnumerable userIds) @@ -36,34 +32,15 @@ public TwoFactorIsEnabledQuery( return result; } - if (_featureService.IsEnabled(FeatureFlagKeys.PremiumAccessCacheCheck)) + var userDetails = await _userRepository.GetManyWithCalculatedPremiumAsync([.. userIds]); + foreach (var userDetail in userDetails) { - var users = await _userRepository.GetManyAsync([.. userIds]); - var premiumStatus = await _premiumAccessQuery.CanAccessPremiumAsync(users); - - foreach (var user in users) - { - result.Add( - (user.Id, - await TwoFactorEnabledAsync( - user.GetTwoFactorProviders(), - () => Task.FromResult(premiumStatus.GetValueOrDefault(user.Id, false)) - )) - ); - } - } - else - { - var userDetails = await _userRepository.GetManyWithCalculatedPremiumAsync([.. userIds]); - foreach (var userDetail in userDetails) - { - result.Add( - (userDetail.Id, - await TwoFactorEnabledAsync(userDetail.GetTwoFactorProviders(), - () => Task.FromResult(userDetail.HasPremiumAccess)) - ) - ); - } + result.Add( + (userDetail.Id, + await TwoFactorEnabledAsync(userDetail.GetTwoFactorProviders(), + () => Task.FromResult(userDetail.HasPremiumAccess)) + ) + ); } return result; @@ -106,43 +83,102 @@ public async Task TwoFactorIsEnabledAsync(ITwoFactorProvidersUser user) return false; } - if (_featureService.IsEnabled(FeatureFlagKeys.PremiumAccessCacheCheck)) - { - // Try to get premium status without fetching User entity if possible - bool hasPersonalPremium; - if (user is User userEntity) + return await TwoFactorEnabledAsync( + user.GetTwoFactorProviders(), + async () => { - hasPersonalPremium = userEntity.Premium; - } - else if (user is OrganizationUserUserDetails orgUserDetails) + 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 _premiumAccessQuery.CanAccessPremiumAsync(users); + + foreach (var user in users) + { + result.Add( + (user.Id, + await TwoFactorEnabledAsync( + user.GetTwoFactorProviders(), + () => Task.FromResult(premiumStatus.GetValueOrDefault(user.Id, false)) + )) + ); + } + + 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) { - hasPersonalPremium = orgUserDetails.Premium.GetValueOrDefault(false); + var hasTwoFactor = twoFactorResults.FirstOrDefault(res => res.userId == userId.Value).twoFactorIsEnabled; + result.Add((user, hasTwoFactor)); } else { - // Fallback: fetch the User entity - var fetchedUser = await _userRepository.GetByIdAsync(userId.Value); - if (fetchedUser == null) - { - return false; - } - hasPersonalPremium = fetchedUser.Premium; + result.Add((user, false)); } + } + + return result; + } - return await TwoFactorEnabledAsync( - user.GetTwoFactorProviders(), - async () => await _premiumAccessQuery.CanAccessPremiumAsync(userId.Value, hasPersonalPremium)); + public async Task TwoFactorIsEnabledVNextAsync(ITwoFactorProvidersUser user) + { + var userId = user.GetUserId(); + if (!userId.HasValue) + { + return false; + } + + // Try to get premium status without fetching User entity if possible + bool hasPersonalPremium; + if (user is User userEntity) + { + hasPersonalPremium = userEntity.Premium; + } + else if (user is OrganizationUserUserDetails orgUserDetails) + { + hasPersonalPremium = orgUserDetails.Premium.GetValueOrDefault(false); } else { - return await TwoFactorEnabledAsync( - user.GetTwoFactorProviders(), - async () => - { - var calcUser = await _userRepository.GetCalculatedPremiumAsync(userId.Value); - return calcUser?.HasPremiumAccess ?? false; - }); + // Fallback: fetch the User entity + var fetchedUser = await _userRepository.GetByIdAsync(userId.Value); + if (fetchedUser == null) + { + return false; + } + hasPersonalPremium = fetchedUser.Premium; } + + return await TwoFactorEnabledAsync( + user.GetTwoFactorProviders(), + async () => await _premiumAccessQuery.CanAccessPremiumAsync(userId.Value, hasPersonalPremium)); } /// diff --git a/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs b/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs index adeac45d062a..7619331009c6 100644 --- a/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs +++ b/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs @@ -1,5 +1,6 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; +using Bit.Core.Auth.UserFeatures.PremiumAccess; using Bit.Core.Auth.UserFeatures.TwoFactorAuth; using Bit.Core.Entities; using Bit.Core.Models.Data; @@ -404,6 +405,503 @@ 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() + .CanAccessPremiumAsync(Arg.Is>(u => u.All(users.Contains))) + .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() + .CanAccessPremiumAsync(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() + .CanAccessPremiumAsync(Arg.Is>(u => u.All(users.Contains))) + .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() + .CanAccessPremiumAsync(Arg.Is>(u => u.All(users.Contains))) + .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() + .CanAccessPremiumAsync(Arg.Is>(u => u.All(users.Contains))) + .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] + public async Task TwoFactorIsEnabledVNextAsync_SingleUser_UserIdNull_ReturnsFalse( + SutProvider sutProvider) + { + // Arrange + var user = new TestTwoFactorProviderUser + { + Id = null + }; + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledVNextAsync(user); + + // Assert + Assert.False(result); + } + + [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() + .CanAccessPremiumAsync(default(Guid), default); + } + + [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() + .CanAccessPremiumAsync(default(Guid), default); + } + + [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() + .CanAccessPremiumAsync(user.Id, false) + .Returns(false); + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledVNextAsync(user); + + // Assert + Assert.False(result); + + await sutProvider.GetDependency() + .Received(1) + .CanAccessPremiumAsync(user.Id, false); + } + + [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() + .CanAccessPremiumAsync(user.Id, true) + .Returns(true); + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledVNextAsync(user); + + // Assert + Assert.True(result); + + await sutProvider.GetDependency() + .Received(1) + .CanAccessPremiumAsync(user.Id, true); + } + + [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() + .CanAccessPremiumAsync(user.Id, false) + .Returns(true); // Has premium from org + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledVNextAsync(user); + + // Assert + Assert.True(result); + + await sutProvider.GetDependency() + .Received(1) + .CanAccessPremiumAsync(user.Id, false); + } + + [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() + .CanAccessPremiumAsync(default(Guid), default); + } + + [Theory] + [BitAutoData(TwoFactorProviderType.Duo)] + [BitAutoData(TwoFactorProviderType.YubiKey)] + public async Task TwoFactorIsEnabledVNextAsync_SingleUser_OrganizationUserUserDetails_WithPremium_ReturnsTrue( + TwoFactorProviderType premiumProviderType, + SutProvider sutProvider, + OrganizationUserUserDetails orgUserDetails) + { + // Arrange + var twoFactorProviders = new Dictionary + { + { premiumProviderType, new TwoFactorProvider { Enabled = true } } + }; + + orgUserDetails.Premium = false; + orgUserDetails.TwoFactorProviders = JsonHelpers.LegacySerialize(twoFactorProviders, JsonHelpers.LegacyEnumKeyResolver); + + sutProvider.GetDependency() + .CanAccessPremiumAsync(orgUserDetails.UserId!.Value, false) + .Returns(true); + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledVNextAsync(orgUserDetails); + + // Assert + Assert.True(result); + + await sutProvider.GetDependency() + .Received(1) + .CanAccessPremiumAsync(orgUserDetails.UserId.Value, false); + } + + [Theory] + [BitAutoData(TwoFactorProviderType.Duo)] + [BitAutoData(TwoFactorProviderType.YubiKey)] + public async Task TwoFactorIsEnabledVNextAsync_SingleUser_UnknownType_FetchesUser( + TwoFactorProviderType premiumProviderType, + SutProvider sutProvider, + User fetchedUser) + { + // Arrange + var twoFactorProviders = new Dictionary + { + { premiumProviderType, new TwoFactorProvider { Enabled = true } } + }; + + var testUser = new TestTwoFactorProviderUser + { + Id = fetchedUser.Id, + Premium = false, + TwoFactorProviders = JsonHelpers.LegacySerialize(twoFactorProviders, JsonHelpers.LegacyEnumKeyResolver) + }; + + fetchedUser.Premium = false; + + sutProvider.GetDependency() + .GetByIdAsync(fetchedUser.Id) + .Returns(fetchedUser); + + sutProvider.GetDependency() + .CanAccessPremiumAsync(fetchedUser.Id, false) + .Returns(true); + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledVNextAsync(testUser); + + // Assert + Assert.True(result); + + await sutProvider.GetDependency() + .Received(1) + .GetByIdAsync(fetchedUser.Id); + + await sutProvider.GetDependency() + .Received(1) + .CanAccessPremiumAsync(fetchedUser.Id, false); + } + private class TestTwoFactorProviderUser : ITwoFactorProvidersUser { public Guid? Id { get; set; } @@ -418,10 +916,5 @@ public Dictionary GetTwoFactorProvider { return Id; } - - public bool GetPremium() - { - return Premium; - } } } From 57252a7ee93eb42dbb9e3aca8fe4ec44fe995724 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Fri, 5 Dec 2025 15:51:28 +0000 Subject: [PATCH 11/16] Refactor IPremiumAccessQuery and PremiumAccessQuery to remove the overloaded CanAccessPremiumAsync method. Update related methods to streamline premium access checks using the User object directly. Enhance test coverage by removing obsolete tests and ensuring proper functionality with the new method signatures. --- .../PremiumAccess/IPremiumAccessQuery.cs | 9 -- .../PremiumAccess/PremiumAccessQuery.cs | 9 +- .../Interfaces/ITwoFactorIsEnabledQuery.cs | 3 +- .../TwoFactorAuth/TwoFactorIsEnabledQuery.cs | 32 +---- .../PremiumAccess/PremiumAccessQueryTests.cs | 74 ------------ .../TwoFactorIsEnabledQueryTests.cs | 114 ++---------------- 6 files changed, 15 insertions(+), 226 deletions(-) diff --git a/src/Core/Auth/UserFeatures/PremiumAccess/IPremiumAccessQuery.cs b/src/Core/Auth/UserFeatures/PremiumAccess/IPremiumAccessQuery.cs index ebd7aec40246..dfea0cb6592b 100644 --- a/src/Core/Auth/UserFeatures/PremiumAccess/IPremiumAccessQuery.cs +++ b/src/Core/Auth/UserFeatures/PremiumAccess/IPremiumAccessQuery.cs @@ -22,15 +22,6 @@ public interface IPremiumAccessQuery /// True if user can access premium features; false otherwise Task CanAccessPremiumAsync(User user); - /// - /// Checks if a user has access to premium features (personal subscription or organization). - /// Use this overload when you already know the personal premium status and only need to check organization premium. - /// - /// The user ID to check for premium access - /// Whether the user has a personal premium subscription - /// True if user can access premium features; false otherwise - Task CanAccessPremiumAsync(Guid userId, bool hasPersonalPremium); - /// /// 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). diff --git a/src/Core/Auth/UserFeatures/PremiumAccess/PremiumAccessQuery.cs b/src/Core/Auth/UserFeatures/PremiumAccess/PremiumAccessQuery.cs index 8da0f120e08a..1489c4b3a486 100644 --- a/src/Core/Auth/UserFeatures/PremiumAccess/PremiumAccessQuery.cs +++ b/src/Core/Auth/UserFeatures/PremiumAccess/PremiumAccessQuery.cs @@ -22,17 +22,12 @@ public PremiumAccessQuery( public async Task CanAccessPremiumAsync(User user) { - return await CanAccessPremiumAsync(user.Id, user.Premium); - } - - public async Task CanAccessPremiumAsync(Guid userId, bool hasPersonalPremium) - { - if (hasPersonalPremium) + if (user.Premium) { return true; } - return await HasPremiumFromOrganizationAsync(userId); + return await HasPremiumFromOrganizationAsync(user.Id); } public async Task HasPremiumFromOrganizationAsync(Guid userId) diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/Interfaces/ITwoFactorIsEnabledQuery.cs b/src/Core/Auth/UserFeatures/TwoFactorAuth/Interfaces/ITwoFactorIsEnabledQuery.cs index 9beb658a32c8..453249343c4f 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; @@ -42,5 +43,5 @@ public interface ITwoFactorIsEnabledQuery /// This version uses PremiumAccessQuery with cached organization abilities for better performance. /// /// The user to check. - Task TwoFactorIsEnabledVNextAsync(ITwoFactorProvidersUser user); + Task TwoFactorIsEnabledVNextAsync(User user); } diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs b/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs index 9ed022184f0a..3809e01e686f 100644 --- a/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs +++ b/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs @@ -6,7 +6,6 @@ using Bit.Core.Auth.UserFeatures.PremiumAccess; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Entities; -using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth; @@ -147,38 +146,11 @@ await TwoFactorEnabledAsync( return result; } - public async Task TwoFactorIsEnabledVNextAsync(ITwoFactorProvidersUser user) + public async Task TwoFactorIsEnabledVNextAsync(User user) { - var userId = user.GetUserId(); - if (!userId.HasValue) - { - return false; - } - - // Try to get premium status without fetching User entity if possible - bool hasPersonalPremium; - if (user is User userEntity) - { - hasPersonalPremium = userEntity.Premium; - } - else if (user is OrganizationUserUserDetails orgUserDetails) - { - hasPersonalPremium = orgUserDetails.Premium.GetValueOrDefault(false); - } - else - { - // Fallback: fetch the User entity - var fetchedUser = await _userRepository.GetByIdAsync(userId.Value); - if (fetchedUser == null) - { - return false; - } - hasPersonalPremium = fetchedUser.Premium; - } - return await TwoFactorEnabledAsync( user.GetTwoFactorProviders(), - async () => await _premiumAccessQuery.CanAccessPremiumAsync(userId.Value, hasPersonalPremium)); + async () => await _premiumAccessQuery.CanAccessPremiumAsync(user)); } /// diff --git a/test/Core.Test/Auth/UserFeatures/PremiumAccess/PremiumAccessQueryTests.cs b/test/Core.Test/Auth/UserFeatures/PremiumAccess/PremiumAccessQueryTests.cs index b30fec46e6fc..43b57ac29fd7 100644 --- a/test/Core.Test/Auth/UserFeatures/PremiumAccess/PremiumAccessQueryTests.cs +++ b/test/Core.Test/Auth/UserFeatures/PremiumAccess/PremiumAccessQueryTests.cs @@ -93,80 +93,6 @@ public async Task CanAccessPremiumAsync_WithUser_WhenUserHasNoPersonalPremiumAnd Assert.False(result); } - [Theory, BitAutoData] - public async Task CanAccessPremiumAsync_WithGuidAndPremiumFlag_WhenHasPersonalPremium_ReturnsTrue( - Guid userId, - SutProvider sutProvider) - { - // Act - var result = await sutProvider.Sut.CanAccessPremiumAsync(userId, hasPersonalPremium: true); - - // Assert - Assert.True(result); - - // Should not call repository since personal premium is enough - await sutProvider.GetDependency() - .DidNotReceive() - .GetManyByUserAsync(Arg.Any()); - } - - [Theory, BitAutoData] - public async Task CanAccessPremiumAsync_WithGuidAndPremiumFlag_WhenNoPersonalPremiumButHasOrgPremium_ReturnsTrue( - Guid userId, - OrganizationUser orgUser, - SutProvider sutProvider) - { - // Arrange - orgUser.UserId = userId; - - var orgAbilities = new Dictionary - { - { - orgUser.OrganizationId, new OrganizationAbility - { - Id = orgUser.OrganizationId, - UsersGetPremium = true, - Enabled = true - } - } - }; - - sutProvider.GetDependency() - .GetManyByUserAsync(userId) - .Returns(new List { orgUser }); - - sutProvider.GetDependency() - .GetOrganizationAbilitiesAsync() - .Returns(orgAbilities); - - // Act - var result = await sutProvider.Sut.CanAccessPremiumAsync(userId, hasPersonalPremium: false); - - // Assert - Assert.True(result); - } - - [Theory, BitAutoData] - public async Task CanAccessPremiumAsync_WithGuidAndPremiumFlag_WhenNoPersonalPremiumAndNoOrgPremium_ReturnsFalse( - Guid userId, - SutProvider sutProvider) - { - // Arrange - sutProvider.GetDependency() - .GetManyByUserAsync(userId) - .Returns(new List()); - - sutProvider.GetDependency() - .GetOrganizationAbilitiesAsync() - .Returns(new Dictionary()); - - // Act - var result = await sutProvider.Sut.CanAccessPremiumAsync(userId, hasPersonalPremium: false); - - // Assert - Assert.False(result); - } - [Theory, BitAutoData] public async Task HasPremiumFromOrganizationAsync_WhenUserHasNoOrganizations_ReturnsFalse( Guid userId, diff --git a/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs b/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs index 7619331009c6..098e6ab95216 100644 --- a/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs +++ b/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs @@ -634,24 +634,6 @@ await sutProvider.GetDependency() .GetManyAsync(default); } - [Theory] - [BitAutoData] - public async Task TwoFactorIsEnabledVNextAsync_SingleUser_UserIdNull_ReturnsFalse( - SutProvider sutProvider) - { - // Arrange - var user = new TestTwoFactorProviderUser - { - Id = null - }; - - // Act - var result = await sutProvider.Sut.TwoFactorIsEnabledVNextAsync(user); - - // Assert - Assert.False(result); - } - [Theory] [BitAutoData(TwoFactorProviderType.Authenticator)] [BitAutoData(TwoFactorProviderType.Email)] @@ -681,7 +663,7 @@ public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithProviderTypeNotReq // Should not need to check premium access for free providers await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .CanAccessPremiumAsync(default(Guid), default); + .CanAccessPremiumAsync(Arg.Any()); } [Theory] @@ -706,7 +688,7 @@ public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithNoTwoFactorEnabled await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .CanAccessPremiumAsync(default(Guid), default); + .CanAccessPremiumAsync(Arg.Any()); } [Theory] @@ -727,7 +709,7 @@ public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithProviderTypeRequir user.SetTwoFactorProviders(twoFactorProviders); sutProvider.GetDependency() - .CanAccessPremiumAsync(user.Id, false) + .CanAccessPremiumAsync(user) .Returns(false); // Act @@ -738,7 +720,7 @@ public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithProviderTypeRequir await sutProvider.GetDependency() .Received(1) - .CanAccessPremiumAsync(user.Id, false); + .CanAccessPremiumAsync(user); } [Theory] @@ -759,7 +741,7 @@ public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithProviderTypeRequir user.SetTwoFactorProviders(twoFactorProviders); sutProvider.GetDependency() - .CanAccessPremiumAsync(user.Id, true) + .CanAccessPremiumAsync(user) .Returns(true); // Act @@ -770,7 +752,7 @@ public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithProviderTypeRequir await sutProvider.GetDependency() .Received(1) - .CanAccessPremiumAsync(user.Id, true); + .CanAccessPremiumAsync(user); } [Theory] @@ -791,7 +773,7 @@ public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithProviderTypeRequir user.SetTwoFactorProviders(twoFactorProviders); sutProvider.GetDependency() - .CanAccessPremiumAsync(user.Id, false) + .CanAccessPremiumAsync(user) .Returns(true); // Has premium from org // Act @@ -802,7 +784,7 @@ public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithProviderTypeRequir await sutProvider.GetDependency() .Received(1) - .CanAccessPremiumAsync(user.Id, false); + .CanAccessPremiumAsync(user); } [Theory] @@ -821,85 +803,7 @@ public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithNullTwoFactorProvi Assert.False(result); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .CanAccessPremiumAsync(default(Guid), default); - } - - [Theory] - [BitAutoData(TwoFactorProviderType.Duo)] - [BitAutoData(TwoFactorProviderType.YubiKey)] - public async Task TwoFactorIsEnabledVNextAsync_SingleUser_OrganizationUserUserDetails_WithPremium_ReturnsTrue( - TwoFactorProviderType premiumProviderType, - SutProvider sutProvider, - OrganizationUserUserDetails orgUserDetails) - { - // Arrange - var twoFactorProviders = new Dictionary - { - { premiumProviderType, new TwoFactorProvider { Enabled = true } } - }; - - orgUserDetails.Premium = false; - orgUserDetails.TwoFactorProviders = JsonHelpers.LegacySerialize(twoFactorProviders, JsonHelpers.LegacyEnumKeyResolver); - - sutProvider.GetDependency() - .CanAccessPremiumAsync(orgUserDetails.UserId!.Value, false) - .Returns(true); - - // Act - var result = await sutProvider.Sut.TwoFactorIsEnabledVNextAsync(orgUserDetails); - - // Assert - Assert.True(result); - - await sutProvider.GetDependency() - .Received(1) - .CanAccessPremiumAsync(orgUserDetails.UserId.Value, false); - } - - [Theory] - [BitAutoData(TwoFactorProviderType.Duo)] - [BitAutoData(TwoFactorProviderType.YubiKey)] - public async Task TwoFactorIsEnabledVNextAsync_SingleUser_UnknownType_FetchesUser( - TwoFactorProviderType premiumProviderType, - SutProvider sutProvider, - User fetchedUser) - { - // Arrange - var twoFactorProviders = new Dictionary - { - { premiumProviderType, new TwoFactorProvider { Enabled = true } } - }; - - var testUser = new TestTwoFactorProviderUser - { - Id = fetchedUser.Id, - Premium = false, - TwoFactorProviders = JsonHelpers.LegacySerialize(twoFactorProviders, JsonHelpers.LegacyEnumKeyResolver) - }; - - fetchedUser.Premium = false; - - sutProvider.GetDependency() - .GetByIdAsync(fetchedUser.Id) - .Returns(fetchedUser); - - sutProvider.GetDependency() - .CanAccessPremiumAsync(fetchedUser.Id, false) - .Returns(true); - - // Act - var result = await sutProvider.Sut.TwoFactorIsEnabledVNextAsync(testUser); - - // Assert - Assert.True(result); - - await sutProvider.GetDependency() - .Received(1) - .GetByIdAsync(fetchedUser.Id); - - await sutProvider.GetDependency() - .Received(1) - .CanAccessPremiumAsync(fetchedUser.Id, false); + .CanAccessPremiumAsync(Arg.Any()); } private class TestTwoFactorProviderUser : ITwoFactorProvidersUser From bf0cc7a7dafb69ee264932c65db826e1baac52d5 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Fri, 5 Dec 2025 16:04:33 +0000 Subject: [PATCH 12/16] Add new sync static method to determine if TwoFactor is enabled --- .../TwoFactorAuth/TwoFactorIsEnabledQuery.cs | 60 +++++++++++++++---- 1 file changed, 50 insertions(+), 10 deletions(-) diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs b/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs index 3809e01e686f..3c1fb10b5f11 100644 --- a/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs +++ b/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs @@ -104,13 +104,11 @@ public async Task TwoFactorIsEnabledAsync(ITwoFactorProvidersUser user) foreach (var user in users) { - result.Add( - (user.Id, - await TwoFactorEnabledAsync( - user.GetTwoFactorProviders(), - () => Task.FromResult(premiumStatus.GetValueOrDefault(user.Id, false)) - )) - ); + var twoFactorProviders = user.GetTwoFactorProviders(); + var hasPremiumAccess = premiumStatus.GetValueOrDefault(user.Id, false); + var twoFactorIsEnabled = TwoFactorIsEnabled(twoFactorProviders, hasPremiumAccess); + + result.Add((user.Id, twoFactorIsEnabled)); } return result; @@ -148,9 +146,51 @@ await TwoFactorEnabledAsync( public async Task TwoFactorIsEnabledVNextAsync(User user) { - return await TwoFactorEnabledAsync( - user.GetTwoFactorProviders(), - async () => await _premiumAccessQuery.CanAccessPremiumAsync(user)); + var providers = user.GetTwoFactorProviders(); + var hasPremiumAccess = await _premiumAccessQuery.CanAccessPremiumAsync(user); + var twoFactorIsEnabled = TwoFactorIsEnabled(providers, hasPremiumAccess); + + return twoFactorIsEnabled; + } + + /// + /// 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; } /// From 65941d4300e497201a81ab82d30eb754d8e1b903 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Fri, 5 Dec 2025 16:06:34 +0000 Subject: [PATCH 13/16] Enhance XML documentation for Premium property in OrganizationUserUserDetails and User classes to clarify its usage and limitations regarding personal and organizational premium access. --- .../OrganizationUsers/OrganizationUserUserDetails.cs | 4 +++- src/Core/Entities/User.cs | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs index a760f8d8e327..00ba706a415f 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs @@ -21,7 +21,9 @@ public class OrganizationUserUserDetails : IExternal, ITwoFactorProvidersUser, I public string AvatarColor { get; set; } public string TwoFactorProviders { get; set; } /// - /// User's personal premium subscription status. Does not reflect organization premium access. + /// 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; } diff --git a/src/Core/Entities/User.cs b/src/Core/Entities/User.cs index d77cb9c52491..669e32bcbe8b 100644 --- a/src/Core/Entities/User.cs +++ b/src/Core/Entities/User.cs @@ -71,7 +71,8 @@ public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFac public string? SecurityState { get; set; } /// /// Indicates whether the user has a personal premium subscription. - /// Does not include premium access from organizations. + /// 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; } From 2a966e3dcfd922789f29dc7c733500fd02db2474 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Fri, 5 Dec 2025 16:35:36 +0000 Subject: [PATCH 14/16] Refactor IPremiumAccessQuery and PremiumAccessQuery to replace User parameter with Guid for user ID in CanAccessPremiumAsync methods. Update related methods and tests to streamline premium access checks and improve clarity in method signatures. --- .../PremiumAccess/IPremiumAccessQuery.cs | 14 +- .../PremiumAccess/PremiumAccessQuery.cs | 86 +-- .../TwoFactorAuth/TwoFactorIsEnabledQuery.cs | 7 +- .../PremiumAccess/PremiumAccessQueryTests.cs | 578 +++--------------- .../TwoFactorIsEnabledQueryTests.cs | 28 +- 5 files changed, 122 insertions(+), 591 deletions(-) diff --git a/src/Core/Auth/UserFeatures/PremiumAccess/IPremiumAccessQuery.cs b/src/Core/Auth/UserFeatures/PremiumAccess/IPremiumAccessQuery.cs index dfea0cb6592b..01cea983a157 100644 --- a/src/Core/Auth/UserFeatures/PremiumAccess/IPremiumAccessQuery.cs +++ b/src/Core/Auth/UserFeatures/PremiumAccess/IPremiumAccessQuery.cs @@ -1,6 +1,4 @@ -using Bit.Core.Entities; - -namespace Bit.Core.Auth.UserFeatures.PremiumAccess; +namespace Bit.Core.Auth.UserFeatures.PremiumAccess; /// /// Query for checking premium access status for users. @@ -18,9 +16,9 @@ public interface IPremiumAccessQuery /// 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 to check for premium access + /// The user ID to check for premium access /// True if user can access premium features; false otherwise - Task CanAccessPremiumAsync(User user); + Task CanAccessPremiumAsync(Guid userId); /// /// Checks if a user has access to premium features through organization membership only. @@ -32,10 +30,10 @@ public interface IPremiumAccessQuery /// /// Checks if multiple users have access to premium features (optimized bulk operation). - /// Uses cached organization abilities and minimizes database queries. + /// Uses existing stored procedure that calculates premium from personal subscriptions and organizations. /// - /// The users to check for premium access + /// The user IDs to check for premium access /// Dictionary mapping user IDs to their premium access status (personal or through organization) - Task> CanAccessPremiumAsync(IEnumerable users); + Task> CanAccessPremiumAsync(IEnumerable userIds); } diff --git a/src/Core/Auth/UserFeatures/PremiumAccess/PremiumAccessQuery.cs b/src/Core/Auth/UserFeatures/PremiumAccess/PremiumAccessQuery.cs index 1489c4b3a486..f4ec353539fd 100644 --- a/src/Core/Auth/UserFeatures/PremiumAccess/PremiumAccessQuery.cs +++ b/src/Core/Auth/UserFeatures/PremiumAccess/PremiumAccessQuery.cs @@ -1,92 +1,42 @@ -using Bit.Core.Entities; -using Bit.Core.Repositories; -using Bit.Core.Services; +using Bit.Core.Repositories; namespace Bit.Core.Auth.UserFeatures.PremiumAccess; /// -/// Query for checking premium access status for users using cached organization abilities. +/// 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 PremiumAccessQuery : IPremiumAccessQuery { - private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly IApplicationCacheService _applicationCacheService; + private readonly IUserRepository _userRepository; - public PremiumAccessQuery( - IOrganizationUserRepository organizationUserRepository, - IApplicationCacheService applicationCacheService) + public PremiumAccessQuery(IUserRepository userRepository) { - _organizationUserRepository = organizationUserRepository; - _applicationCacheService = applicationCacheService; + _userRepository = userRepository; } - public async Task CanAccessPremiumAsync(User user) + public async Task CanAccessPremiumAsync(Guid userId) { - if (user.Premium) - { - return true; - } - - return await HasPremiumFromOrganizationAsync(user.Id); + var user = await _userRepository.GetCalculatedPremiumAsync(userId); + return user?.HasPremiumAccess ?? false; } - public async Task HasPremiumFromOrganizationAsync(Guid userId) + public async Task> CanAccessPremiumAsync(IEnumerable userIds) { - // Note: GetManyByUserAsync only returns Accepted and Confirmed status org users - var orgUsers = await _organizationUserRepository.GetManyByUserAsync(userId); - if (!orgUsers.Any()) - { - return false; - } - - var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); - return orgUsers.Any(ou => - orgAbilities.TryGetValue(ou.OrganizationId, out var orgAbility) && - orgAbility.UsersGetPremium && - orgAbility.Enabled); + var usersWithPremium = await _userRepository.GetManyWithCalculatedPremiumAsync(userIds); + return usersWithPremium.ToDictionary(u => u.Id, u => u.HasPremiumAccess); } - public async Task> CanAccessPremiumAsync(IEnumerable users) + public async Task HasPremiumFromOrganizationAsync(Guid userId) { - var result = new Dictionary(); - var usersList = users.ToList(); - - if (!usersList.Any()) + var user = await _userRepository.GetCalculatedPremiumAsync(userId); + if (user == null) { - return result; - } - - var userIds = usersList.Select(u => u.Id).ToList(); - - // Get all org memberships for these users in one query - // Note: GetManyByManyUsersAsync only returns Accepted and Confirmed status org users - var allOrgUsers = await _organizationUserRepository.GetManyByManyUsersAsync(userIds); - var orgUsersGrouped = allOrgUsers - .Where(ou => ou.UserId.HasValue) - .GroupBy(ou => ou.UserId!.Value) - .ToDictionary(g => g.Key, g => g.ToList()); - - var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); - - foreach (var user in usersList) - { - var hasPersonalPremium = user.Premium; - if (hasPersonalPremium) - { - result[user.Id] = true; - continue; - } - - var hasPremiumFromOrg = orgUsersGrouped.TryGetValue(user.Id, out var userOrgs) && - userOrgs.Any(ou => - orgAbilities.TryGetValue(ou.OrganizationId, out var orgAbility) && - orgAbility.UsersGetPremium && - orgAbility.Enabled); - - result[user.Id] = hasPremiumFromOrg; + return false; } - return result; + // Has org premium if has premium access but not personal premium + return user.HasPremiumAccess && !user.Premium; } } diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs b/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs index 3c1fb10b5f11..5c401f535857 100644 --- a/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs +++ b/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs @@ -100,7 +100,7 @@ public async Task TwoFactorIsEnabledAsync(ITwoFactorProvidersUser user) } var users = await _userRepository.GetManyAsync([.. userIds]); - var premiumStatus = await _premiumAccessQuery.CanAccessPremiumAsync(users); + var premiumStatus = await _premiumAccessQuery.CanAccessPremiumAsync(userIds); foreach (var user in users) { @@ -147,10 +147,9 @@ public async Task TwoFactorIsEnabledAsync(ITwoFactorProvidersUser user) public async Task TwoFactorIsEnabledVNextAsync(User user) { var providers = user.GetTwoFactorProviders(); - var hasPremiumAccess = await _premiumAccessQuery.CanAccessPremiumAsync(user); - var twoFactorIsEnabled = TwoFactorIsEnabled(providers, hasPremiumAccess); + var hasPremium = await _premiumAccessQuery.CanAccessPremiumAsync(user.Id); - return twoFactorIsEnabled; + return TwoFactorIsEnabled(providers, hasPremium); } /// diff --git a/test/Core.Test/Auth/UserFeatures/PremiumAccess/PremiumAccessQueryTests.cs b/test/Core.Test/Auth/UserFeatures/PremiumAccess/PremiumAccessQueryTests.cs index 43b57ac29fd7..cf6e154fc596 100644 --- a/test/Core.Test/Auth/UserFeatures/PremiumAccess/PremiumAccessQueryTests.cs +++ b/test/Core.Test/Auth/UserFeatures/PremiumAccess/PremiumAccessQueryTests.cs @@ -1,8 +1,6 @@ using Bit.Core.Auth.UserFeatures.PremiumAccess; -using Bit.Core.Entities; -using Bit.Core.Models.Data.Organizations; +using Bit.Core.Models.Data; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -14,614 +12,200 @@ namespace Bit.Core.Test.Auth.UserFeatures.PremiumAccess; public class PremiumAccessQueryTests { [Theory, BitAutoData] - public async Task CanAccessPremiumAsync_WithUser_WhenUserHasPersonalPremium_ReturnsTrue( - User user, + public async Task CanAccessPremiumAsync_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.CanAccessPremiumAsync(user); + var result = await sutProvider.Sut.CanAccessPremiumAsync(user.Id); // Assert Assert.True(result); - - // Should not call repository since personal premium is enough - await sutProvider.GetDependency() - .DidNotReceive() - .GetManyByUserAsync(Arg.Any()); } [Theory, BitAutoData] - public async Task CanAccessPremiumAsync_WithUser_WhenUserHasNoPersonalPremiumButHasOrgPremium_ReturnsTrue( - User user, - OrganizationUser orgUser, + public async Task CanAccessPremiumAsync_WhenUserHasNoPersonalPremiumButHasOrgPremium_ReturnsTrue( + UserWithCalculatedPremium user, SutProvider sutProvider) { // Arrange user.Premium = false; - orgUser.UserId = user.Id; - - var orgAbilities = new Dictionary - { - { - orgUser.OrganizationId, new OrganizationAbility - { - Id = orgUser.OrganizationId, - UsersGetPremium = true, - Enabled = true - } - } - }; - - sutProvider.GetDependency() - .GetManyByUserAsync(user.Id) - .Returns(new List { orgUser }); - - sutProvider.GetDependency() - .GetOrganizationAbilitiesAsync() - .Returns(orgAbilities); + user.HasPremiumAccess = true; // Has org premium + + sutProvider.GetDependency() + .GetCalculatedPremiumAsync(user.Id) + .Returns(user); // Act - var result = await sutProvider.Sut.CanAccessPremiumAsync(user); + var result = await sutProvider.Sut.CanAccessPremiumAsync(user.Id); // Assert Assert.True(result); } [Theory, BitAutoData] - public async Task CanAccessPremiumAsync_WithUser_WhenUserHasNoPersonalPremiumAndNoOrgPremium_ReturnsFalse( - User user, + public async Task CanAccessPremiumAsync_WhenUserHasNoPersonalPremiumAndNoOrgPremium_ReturnsFalse( + UserWithCalculatedPremium user, SutProvider sutProvider) { // Arrange user.Premium = false; + user.HasPremiumAccess = false; - sutProvider.GetDependency() - .GetManyByUserAsync(user.Id) - .Returns(new List()); - - sutProvider.GetDependency() - .GetOrganizationAbilitiesAsync() - .Returns(new Dictionary()); + sutProvider.GetDependency() + .GetCalculatedPremiumAsync(user.Id) + .Returns(user); // Act - var result = await sutProvider.Sut.CanAccessPremiumAsync(user); + var result = await sutProvider.Sut.CanAccessPremiumAsync(user.Id); // Assert Assert.False(result); } [Theory, BitAutoData] - public async Task HasPremiumFromOrganizationAsync_WhenUserHasNoOrganizations_ReturnsFalse( + public async Task CanAccessPremiumAsync_WhenUserNotFound_ReturnsFalse( Guid userId, SutProvider sutProvider) { // Arrange - sutProvider.GetDependency() - .GetManyByUserAsync(userId) - .Returns(new List()); + sutProvider.GetDependency() + .GetCalculatedPremiumAsync(userId) + .Returns((UserWithCalculatedPremium)null); // Act - var result = await sutProvider.Sut.HasPremiumFromOrganizationAsync(userId); + var result = await sutProvider.Sut.CanAccessPremiumAsync(userId); // Assert Assert.False(result); - - // Should not call cache service if user has no organizations - await sutProvider.GetDependency() - .DidNotReceive() - .GetOrganizationAbilitiesAsync(); } [Theory, BitAutoData] - public async Task HasPremiumFromOrganizationAsync_WhenOrgHasPremiumAndEnabled_ReturnsTrue( - Guid userId, - OrganizationUser orgUser, + public async Task HasPremiumFromOrganizationAsync_WhenUserHasNoOrganizations_ReturnsFalse( + UserWithCalculatedPremium user, SutProvider sutProvider) { // Arrange - orgUser.UserId = userId; - - var orgAbilities = new Dictionary - { - { - orgUser.OrganizationId, new OrganizationAbility - { - Id = orgUser.OrganizationId, - UsersGetPremium = true, - Enabled = true - } - } - }; - - sutProvider.GetDependency() - .GetManyByUserAsync(userId) - .Returns(new List { orgUser }); - - sutProvider.GetDependency() - .GetOrganizationAbilitiesAsync() - .Returns(orgAbilities); - - // Act - var result = await sutProvider.Sut.HasPremiumFromOrganizationAsync(userId); - - // Assert - Assert.True(result); - } + user.Premium = false; + user.HasPremiumAccess = false; // No premium from anywhere - [Theory, BitAutoData] - public async Task HasPremiumFromOrganizationAsync_WhenOrgDoesNotHaveUsersGetPremium_ReturnsFalse( - Guid userId, - OrganizationUser orgUser, - SutProvider sutProvider) - { - // Arrange - orgUser.UserId = userId; - - var orgAbilities = new Dictionary - { - { - orgUser.OrganizationId, new OrganizationAbility - { - Id = orgUser.OrganizationId, - UsersGetPremium = false, // No premium for users - Enabled = true - } - } - }; - - sutProvider.GetDependency() - .GetManyByUserAsync(userId) - .Returns(new List { orgUser }); - - sutProvider.GetDependency() - .GetOrganizationAbilitiesAsync() - .Returns(orgAbilities); + sutProvider.GetDependency() + .GetCalculatedPremiumAsync(user.Id) + .Returns(user); // Act - var result = await sutProvider.Sut.HasPremiumFromOrganizationAsync(userId); + var result = await sutProvider.Sut.HasPremiumFromOrganizationAsync(user.Id); // Assert Assert.False(result); } [Theory, BitAutoData] - public async Task HasPremiumFromOrganizationAsync_WhenOrgIsDisabled_ReturnsFalse( - Guid userId, - OrganizationUser orgUser, + public async Task HasPremiumFromOrganizationAsync_WhenUserHasPremiumFromOrg_ReturnsTrue( + UserWithCalculatedPremium user, SutProvider sutProvider) { // Arrange - orgUser.UserId = userId; - - var orgAbilities = new Dictionary - { - { - orgUser.OrganizationId, new OrganizationAbility - { - Id = orgUser.OrganizationId, - UsersGetPremium = true, - Enabled = false // Organization disabled - } - } - }; - - sutProvider.GetDependency() - .GetManyByUserAsync(userId) - .Returns(new List { orgUser }); - - sutProvider.GetDependency() - .GetOrganizationAbilitiesAsync() - .Returns(orgAbilities); + 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(userId); + var result = await sutProvider.Sut.HasPremiumFromOrganizationAsync(user.Id); // Assert - Assert.False(result); + Assert.True(result); } [Theory, BitAutoData] - public async Task HasPremiumFromOrganizationAsync_WhenOrgNotInCache_ReturnsFalse( - Guid userId, - OrganizationUser orgUser, + public async Task HasPremiumFromOrganizationAsync_WhenUserHasOnlyPersonalPremium_ReturnsFalse( + UserWithCalculatedPremium user, SutProvider sutProvider) { // Arrange - orgUser.UserId = userId; + user.Premium = true; // Has personal premium + user.HasPremiumAccess = true; - sutProvider.GetDependency() - .GetManyByUserAsync(userId) - .Returns(new List { orgUser }); - - sutProvider.GetDependency() - .GetOrganizationAbilitiesAsync() - .Returns(new Dictionary()); // Empty cache + sutProvider.GetDependency() + .GetCalculatedPremiumAsync(user.Id) + .Returns(user); // Act - var result = await sutProvider.Sut.HasPremiumFromOrganizationAsync(userId); + var result = await sutProvider.Sut.HasPremiumFromOrganizationAsync(user.Id); // Assert - Assert.False(result); + Assert.False(result); // Should return false because premium is from personal, not org } [Theory, BitAutoData] - public async Task HasPremiumFromOrganizationAsync_WhenUserInMultipleOrgs_OnlyOneHasPremium_ReturnsTrue( + public async Task HasPremiumFromOrganizationAsync_WhenUserNotFound_ReturnsFalse( Guid userId, - OrganizationUser orgUser1, - OrganizationUser orgUser2, - OrganizationUser orgUser3, SutProvider sutProvider) { // Arrange - orgUser1.UserId = userId; - orgUser2.UserId = userId; - orgUser3.UserId = userId; - - var orgAbilities = new Dictionary - { - { - orgUser1.OrganizationId, new OrganizationAbility - { - Id = orgUser1.OrganizationId, - UsersGetPremium = false, - Enabled = true - } - }, - { - orgUser2.OrganizationId, new OrganizationAbility - { - Id = orgUser2.OrganizationId, - UsersGetPremium = true, // This one has premium - Enabled = true - } - }, - { - orgUser3.OrganizationId, new OrganizationAbility - { - Id = orgUser3.OrganizationId, - UsersGetPremium = false, - Enabled = true - } - } - }; - - sutProvider.GetDependency() - .GetManyByUserAsync(userId) - .Returns(new List { orgUser1, orgUser2, orgUser3 }); - - sutProvider.GetDependency() - .GetOrganizationAbilitiesAsync() - .Returns(orgAbilities); + sutProvider.GetDependency() + .GetCalculatedPremiumAsync(userId) + .Returns((UserWithCalculatedPremium)null); // Act var result = await sutProvider.Sut.HasPremiumFromOrganizationAsync(userId); // Assert - Assert.True(result); - } - - [Theory, BitAutoData] - public async Task CanAccessPremiumAsync_WhenEmptyUsersList_ReturnsEmptyDictionary( - SutProvider sutProvider) - { - // Arrange - var users = new List(); - - // Act - var result = await sutProvider.Sut.CanAccessPremiumAsync(users); - - // Assert - Assert.Empty(result); - - // Should not call dependencies for empty list - await sutProvider.GetDependency() - .DidNotReceive() - .GetManyByManyUsersAsync(Arg.Any>()); + Assert.False(result); } [Theory, BitAutoData] - public async Task CanAccessPremiumAsync_WhenAllUsersHavePersonalPremium_ReturnsAllTrue( - List users, + public async Task CanAccessPremiumAsync_Bulk_WhenEmptyList_ReturnsEmptyDictionary( SutProvider sutProvider) { // Arrange - foreach (var user in users) - { - user.Premium = true; - } - - sutProvider.GetDependency() - .GetManyByManyUsersAsync(Arg.Any>()) - .Returns(new List()); + var userIds = new List(); - sutProvider.GetDependency() - .GetOrganizationAbilitiesAsync() - .Returns(new Dictionary()); + sutProvider.GetDependency() + .GetManyWithCalculatedPremiumAsync(userIds) + .Returns(new List()); // Act - var result = await sutProvider.Sut.CanAccessPremiumAsync(users); + var result = await sutProvider.Sut.CanAccessPremiumAsync(userIds); // Assert - Assert.Equal(users.Count, result.Count); - foreach (var user in users) - { - Assert.True(result[user.Id]); - } + Assert.Empty(result); } [Theory, BitAutoData] - public async Task CanAccessPremiumAsync_WhenNoUsersHavePremium_ReturnsAllFalse( - List users, + public async Task CanAccessPremiumAsync_Bulk_ReturnsCorrectStatus( + List users, SutProvider sutProvider) { // Arrange - foreach (var user in users) - { - user.Premium = false; - } + users[0].HasPremiumAccess = true; + users[1].HasPremiumAccess = false; + users[2].HasPremiumAccess = true; - sutProvider.GetDependency() - .GetManyByManyUsersAsync(Arg.Any>()) - .Returns(new List()); + var userIds = users.Select(u => u.Id).ToList(); - sutProvider.GetDependency() - .GetOrganizationAbilitiesAsync() - .Returns(new Dictionary()); + sutProvider.GetDependency() + .GetManyWithCalculatedPremiumAsync(userIds) + .Returns(users); // Act - var result = await sutProvider.Sut.CanAccessPremiumAsync(users); + var result = await sutProvider.Sut.CanAccessPremiumAsync(userIds); // Assert Assert.Equal(users.Count, result.Count); - foreach (var user in users) - { - Assert.False(result[user.Id]); - } - } - - [Theory, BitAutoData] - public async Task CanAccessPremiumAsync_WhenSomeUsersHaveOrgPremium_ReturnsCorrectStatus( - User user1, - User user2, - User user3, - OrganizationUser orgUser1, - OrganizationUser orgUser2, - Guid orgId, - SutProvider sutProvider) - { - // Arrange - user1.Premium = false; // Will get premium from org - user2.Premium = true; // Has personal premium - user3.Premium = false; // No premium at all - - orgUser1.UserId = user1.Id; - orgUser1.OrganizationId = orgId; - orgUser2.UserId = user3.Id; - orgUser2.OrganizationId = orgId; - - var users = new List { user1, user2, user3 }; - var orgUsers = new List { orgUser1, orgUser2 }; - - var orgAbilities = new Dictionary - { - { - orgId, new OrganizationAbility - { - Id = orgId, - UsersGetPremium = true, - Enabled = true - } - } - }; - - sutProvider.GetDependency() - .GetManyByManyUsersAsync(Arg.Is>(ids => - ids.Contains(user1.Id) && ids.Contains(user2.Id) && ids.Contains(user3.Id))) - .Returns(orgUsers); - - sutProvider.GetDependency() - .GetOrganizationAbilitiesAsync() - .Returns(orgAbilities); - - // Act - var result = await sutProvider.Sut.CanAccessPremiumAsync(users); - - // Assert - Assert.Equal(3, result.Count); - Assert.True(result[user1.Id]); // Premium from org - Assert.True(result[user2.Id]); // Personal premium - Assert.True(result[user3.Id]); // Premium from org - } - - [Theory, BitAutoData] - public async Task CanAccessPremiumAsync_WhenOrgUsersHaveNoUserId_FiltersThemOut( - User user1, - OrganizationUser orgUser1, - OrganizationUser orgUser2, - Guid orgId, - SutProvider sutProvider) - { - // Arrange - user1.Premium = false; - - orgUser1.UserId = user1.Id; - orgUser1.OrganizationId = orgId; - orgUser2.UserId = null; // This should be filtered out - orgUser2.OrganizationId = orgId; - - var users = new List { user1 }; - var orgUsers = new List { orgUser1, orgUser2 }; - - var orgAbilities = new Dictionary - { - { - orgId, new OrganizationAbility - { - Id = orgId, - UsersGetPremium = true, - Enabled = true - } - } - }; - - sutProvider.GetDependency() - .GetManyByManyUsersAsync(Arg.Any>()) - .Returns(orgUsers); - - sutProvider.GetDependency() - .GetOrganizationAbilitiesAsync() - .Returns(orgAbilities); - - // Act - var result = await sutProvider.Sut.CanAccessPremiumAsync(users); - - // Assert - Assert.Single(result); - Assert.True(result[user1.Id]); - } - - [Theory, BitAutoData] - public async Task CanAccessPremiumAsync_WhenOrgIsDisabled_DoesNotGrantPremium( - User user1, - OrganizationUser orgUser1, - Guid orgId, - SutProvider sutProvider) - { - // Arrange - user1.Premium = false; - orgUser1.UserId = user1.Id; - orgUser1.OrganizationId = orgId; - - var users = new List { user1 }; - var orgUsers = new List { orgUser1 }; - - var orgAbilities = new Dictionary - { - { - orgId, new OrganizationAbility - { - Id = orgId, - UsersGetPremium = true, - Enabled = false // Organization disabled - } - } - }; - - sutProvider.GetDependency() - .GetManyByManyUsersAsync(Arg.Any>()) - .Returns(orgUsers); - - sutProvider.GetDependency() - .GetOrganizationAbilitiesAsync() - .Returns(orgAbilities); - - // Act - var result = await sutProvider.Sut.CanAccessPremiumAsync(users); - - // Assert - Assert.Single(result); - Assert.False(result[user1.Id]); - } - - [Theory, BitAutoData] - public async Task CanAccessPremiumAsync_WhenOrgDoesNotHaveUsersGetPremium_DoesNotGrantPremium( - User user1, - OrganizationUser orgUser1, - Guid orgId, - SutProvider sutProvider) - { - // Arrange - user1.Premium = false; - orgUser1.UserId = user1.Id; - orgUser1.OrganizationId = orgId; - - var users = new List { user1 }; - var orgUsers = new List { orgUser1 }; - - var orgAbilities = new Dictionary - { - { - orgId, new OrganizationAbility - { - Id = orgId, - UsersGetPremium = false, // Premium not available for users - Enabled = true - } - } - }; - - sutProvider.GetDependency() - .GetManyByManyUsersAsync(Arg.Any>()) - .Returns(orgUsers); - - sutProvider.GetDependency() - .GetOrganizationAbilitiesAsync() - .Returns(orgAbilities); - - // Act - var result = await sutProvider.Sut.CanAccessPremiumAsync(users); - - // Assert - Assert.Single(result); - Assert.False(result[user1.Id]); - } - - [Theory, BitAutoData] - public async Task CanAccessPremiumAsync_WhenUserInMultipleOrgs_OnlyOneHasPremium_GrantsPremium( - User user1, - OrganizationUser orgUser1, - OrganizationUser orgUser2, - Guid orgId1, - Guid orgId2, - SutProvider sutProvider) - { - // Arrange - user1.Premium = false; - - orgUser1.UserId = user1.Id; - orgUser1.OrganizationId = orgId1; - orgUser2.UserId = user1.Id; - orgUser2.OrganizationId = orgId2; - - var users = new List { user1 }; - var orgUsers = new List { orgUser1, orgUser2 }; - - var orgAbilities = new Dictionary - { - { - orgId1, new OrganizationAbility - { - Id = orgId1, - UsersGetPremium = false, - Enabled = true - } - }, - { - orgId2, new OrganizationAbility - { - Id = orgId2, - UsersGetPremium = true, // This one grants premium - Enabled = true - } - } - }; - - sutProvider.GetDependency() - .GetManyByManyUsersAsync(Arg.Any>()) - .Returns(orgUsers); - - sutProvider.GetDependency() - .GetOrganizationAbilitiesAsync() - .Returns(orgAbilities); - - // Act - var result = await sutProvider.Sut.CanAccessPremiumAsync(users); - - // Assert - Assert.Single(result); - Assert.True(result[user1.Id]); + Assert.True(result[users[0].Id]); + Assert.False(result[users[1].Id]); + Assert.True(result[users[2].Id]); } } diff --git a/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs b/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs index 098e6ab95216..4a703a736df0 100644 --- a/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs +++ b/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs @@ -436,7 +436,7 @@ public async Task TwoFactorIsEnabledVNextAsync_WithProviderTypeNotRequiringPremi .Returns(users); sutProvider.GetDependency() - .CanAccessPremiumAsync(Arg.Is>(u => u.All(users.Contains))) + .CanAccessPremiumAsync(userIds) .Returns(premiumStatus); // Act @@ -462,7 +462,7 @@ public async Task TwoFactorIsEnabledVNextAsync_DatabaseReturnsEmpty_ResultEmpty( .Returns([]); sutProvider.GetDependency() - .CanAccessPremiumAsync(Arg.Any>()) + .CanAccessPremiumAsync(Arg.Any>()) .Returns(new Dictionary()); // Act @@ -511,7 +511,7 @@ public async Task TwoFactorIsEnabledVNextAsync_WithNoTwoFactorEnabled_ReturnsAll .Returns(users); sutProvider.GetDependency() - .CanAccessPremiumAsync(Arg.Is>(u => u.All(users.Contains))) + .CanAccessPremiumAsync(userIds) .Returns(premiumStatus); // Act @@ -556,7 +556,7 @@ public async Task TwoFactorIsEnabledVNextAsync_WithProviderTypeRequiringPremium_ .Returns(users); sutProvider.GetDependency() - .CanAccessPremiumAsync(Arg.Is>(u => u.All(users.Contains))) + .CanAccessPremiumAsync(userIds) .Returns(premiumStatus); // Act @@ -594,7 +594,7 @@ public async Task TwoFactorIsEnabledVNextAsync_WithNullOrEmptyTwoFactorProviders .Returns(users); sutProvider.GetDependency() - .CanAccessPremiumAsync(Arg.Is>(u => u.All(users.Contains))) + .CanAccessPremiumAsync(userIds) .Returns(premiumStatus); // Act @@ -663,7 +663,7 @@ public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithProviderTypeNotReq // Should not need to check premium access for free providers await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .CanAccessPremiumAsync(Arg.Any()); + .CanAccessPremiumAsync(Arg.Any()); } [Theory] @@ -688,7 +688,7 @@ public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithNoTwoFactorEnabled await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .CanAccessPremiumAsync(Arg.Any()); + .CanAccessPremiumAsync(Arg.Any()); } [Theory] @@ -709,7 +709,7 @@ public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithProviderTypeRequir user.SetTwoFactorProviders(twoFactorProviders); sutProvider.GetDependency() - .CanAccessPremiumAsync(user) + .CanAccessPremiumAsync(user.Id) .Returns(false); // Act @@ -720,7 +720,7 @@ public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithProviderTypeRequir await sutProvider.GetDependency() .Received(1) - .CanAccessPremiumAsync(user); + .CanAccessPremiumAsync(user.Id); } [Theory] @@ -741,7 +741,7 @@ public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithProviderTypeRequir user.SetTwoFactorProviders(twoFactorProviders); sutProvider.GetDependency() - .CanAccessPremiumAsync(user) + .CanAccessPremiumAsync(user.Id) .Returns(true); // Act @@ -752,7 +752,7 @@ public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithProviderTypeRequir await sutProvider.GetDependency() .Received(1) - .CanAccessPremiumAsync(user); + .CanAccessPremiumAsync(user.Id); } [Theory] @@ -773,7 +773,7 @@ public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithProviderTypeRequir user.SetTwoFactorProviders(twoFactorProviders); sutProvider.GetDependency() - .CanAccessPremiumAsync(user) + .CanAccessPremiumAsync(user.Id) .Returns(true); // Has premium from org // Act @@ -784,7 +784,7 @@ public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithProviderTypeRequir await sutProvider.GetDependency() .Received(1) - .CanAccessPremiumAsync(user); + .CanAccessPremiumAsync(user.Id); } [Theory] @@ -803,7 +803,7 @@ public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithNullTwoFactorProvi Assert.False(result); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .CanAccessPremiumAsync(Arg.Any()); + .CanAccessPremiumAsync(Arg.Any()); } private class TestTwoFactorProviderUser : ITwoFactorProvidersUser From 96a3615fcb022114d741a16a81fde885346c152b Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Fri, 5 Dec 2025 16:45:08 +0000 Subject: [PATCH 15/16] Update feature flag references in IUserRepository and IUserService to use 'PremiumAccessQuery' instead of 'PremiumAccessCacheCheck'. Adjust related XML documentation for clarity on premium access methods. --- src/Core/Constants.cs | 2 +- src/Core/Repositories/IUserRepository.cs | 4 ++-- src/Core/Services/IUserService.cs | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index fc381f9a5b20..c3f3cea76ae9 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -144,7 +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 PremiumAccessCacheCheck = "pm-21411-premium-access-cache-check"; + public const string PremiumAccessQuery = "pm-21411-premium-access-query"; /* Architecture */ public const string DesktopMigrationMilestone1 = "desktop-ui-migration-milestone-1"; diff --git a/src/Core/Repositories/IUserRepository.cs b/src/Core/Repositories/IUserRepository.cs index 8badc7f402c9..db9151e0ddb1 100644 --- a/src/Core/Repositories/IUserRepository.cs +++ b/src/Core/Repositories/IUserRepository.cs @@ -23,7 +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 'PremiumAccessCacheCheck' is disabled.")] + [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 @@ -34,7 +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 'PremiumAccessCacheCheck' is disabled.")] + [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 6ff367b3e66f..00d39757c249 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -64,7 +64,7 @@ Task GenerateLicenseAsync(User user, SubscriptionInfo subscriptionI /// /// user being acted on /// true if they can access premium; false otherwise. - [Obsolete("Use IPremiumAccessQuery.CanAccessPremiumAsync instead. This method is only used when feature flag 'PremiumAccessCacheCheck' is disabled.")] + [Obsolete("Use IPremiumAccessQuery.CanAccessPremiumAsync instead. This method is only used when feature flag 'PremiumAccessQuery' is disabled.")] Task CanAccessPremium(User user); /// @@ -75,7 +75,7 @@ Task GenerateLicenseAsync(User user, SubscriptionInfo subscriptionI /// /// /// - [Obsolete("Use IPremiumAccessQuery.HasPremiumFromOrganizationAsync instead. This method is only used when feature flag 'PremiumAccessCacheCheck' is disabled.")] + [Obsolete("Use IPremiumAccessQuery.HasPremiumFromOrganizationAsync instead. This method is only used when feature flag 'PremiumAccessQuery' is disabled.")] Task HasPremiumFromOrganization(User user); Task GenerateSignInTokenAsync(User user, string purpose); From 516824a4e32ee260d8967dc59c4d3c20c9133561 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Fri, 5 Dec 2025 17:12:09 +0000 Subject: [PATCH 16/16] Rename IPremiumAccessQuery to IHasPremiumAccessQuery and move to Billing owned folder --- .../Interfaces/ITwoFactorIsEnabledQuery.cs | 6 +- .../TwoFactorAuth/TwoFactorIsEnabledQuery.cs | 12 ++-- .../UserServiceCollectionExtensions.cs | 4 +- .../Premium/Queries/HasPremiumAccessQuery.cs} | 12 ++-- .../Queries/IHasPremiumAccessQuery.cs} | 10 ++-- src/Core/Services/IUserService.cs | 4 +- .../TwoFactorIsEnabledQueryTests.cs | 58 +++++++++---------- .../Queries/HasPremiumAccessQueryTests.cs} | 52 +++++++++-------- 8 files changed, 82 insertions(+), 76 deletions(-) rename src/Core/{Auth/UserFeatures/PremiumAccess/PremiumAccessQuery.cs => Billing/Premium/Queries/HasPremiumAccessQuery.cs} (78%) rename src/Core/{Auth/UserFeatures/PremiumAccess/IPremiumAccessQuery.cs => Billing/Premium/Queries/IHasPremiumAccessQuery.cs} (89%) rename test/Core.Test/{Auth/UserFeatures/PremiumAccess/PremiumAccessQueryTests.cs => Billing/Premium/Queries/HasPremiumAccessQueryTests.cs} (77%) diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/Interfaces/ITwoFactorIsEnabledQuery.cs b/src/Core/Auth/UserFeatures/TwoFactorAuth/Interfaces/ITwoFactorIsEnabledQuery.cs index 453249343c4f..406b39cd69c2 100644 --- a/src/Core/Auth/UserFeatures/TwoFactorAuth/Interfaces/ITwoFactorIsEnabledQuery.cs +++ b/src/Core/Auth/UserFeatures/TwoFactorAuth/Interfaces/ITwoFactorIsEnabledQuery.cs @@ -26,13 +26,13 @@ public interface ITwoFactorIsEnabledQuery /// /// Returns a list of user IDs and whether two factor is enabled for each user. - /// This version uses PremiumAccessQuery with cached organization abilities for better performance. + /// 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 PremiumAccessQuery with cached organization abilities for better performance. + /// 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 . @@ -40,7 +40,7 @@ public interface ITwoFactorIsEnabledQuery /// /// 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 PremiumAccessQuery with cached organization abilities for better performance. + /// 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 5c401f535857..de8df635a2f5 100644 --- a/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs +++ b/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs @@ -3,8 +3,8 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; -using Bit.Core.Auth.UserFeatures.PremiumAccess; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Billing.Premium.Queries; using Bit.Core.Entities; using Bit.Core.Repositories; @@ -13,14 +13,14 @@ namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth; public class TwoFactorIsEnabledQuery : ITwoFactorIsEnabledQuery { private readonly IUserRepository _userRepository; - private readonly IPremiumAccessQuery _premiumAccessQuery; + private readonly IHasPremiumAccessQuery _hasPremiumAccessQuery; public TwoFactorIsEnabledQuery( IUserRepository userRepository, - IPremiumAccessQuery premiumAccessQuery) + IHasPremiumAccessQuery hasPremiumAccessQuery) { _userRepository = userRepository; - _premiumAccessQuery = premiumAccessQuery; + _hasPremiumAccessQuery = hasPremiumAccessQuery; } public async Task> TwoFactorIsEnabledAsync(IEnumerable userIds) @@ -100,7 +100,7 @@ public async Task TwoFactorIsEnabledAsync(ITwoFactorProvidersUser user) } var users = await _userRepository.GetManyAsync([.. userIds]); - var premiumStatus = await _premiumAccessQuery.CanAccessPremiumAsync(userIds); + var premiumStatus = await _hasPremiumAccessQuery.HasPremiumAccessAsync(userIds); foreach (var user in users) { @@ -147,7 +147,7 @@ public async Task TwoFactorIsEnabledAsync(ITwoFactorProvidersUser user) public async Task TwoFactorIsEnabledVNextAsync(User user) { var providers = user.GetTwoFactorProviders(); - var hasPremium = await _premiumAccessQuery.CanAccessPremiumAsync(user.Id); + var hasPremium = await _hasPremiumAccessQuery.HasPremiumAccessAsync(user.Id); return TwoFactorIsEnabled(providers, hasPremium); } diff --git a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs index fee2cb1b0428..07669c623882 100644 --- a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs +++ b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs @@ -1,6 +1,5 @@ using Bit.Core.Auth.Sso; using Bit.Core.Auth.UserFeatures.DeviceTrust; -using Bit.Core.Auth.UserFeatures.PremiumAccess; using Bit.Core.Auth.UserFeatures.Registration; using Bit.Core.Auth.UserFeatures.Registration.Implementations; using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces; @@ -10,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; @@ -69,7 +69,7 @@ private static void AddWebAuthnLoginCommands(this IServiceCollection services) private static void AddPremiumAccessQueries(this IServiceCollection services) { - services.AddScoped(); + services.AddScoped(); } private static void AddTwoFactorQueries(this IServiceCollection services) diff --git a/src/Core/Auth/UserFeatures/PremiumAccess/PremiumAccessQuery.cs b/src/Core/Billing/Premium/Queries/HasPremiumAccessQuery.cs similarity index 78% rename from src/Core/Auth/UserFeatures/PremiumAccess/PremiumAccessQuery.cs rename to src/Core/Billing/Premium/Queries/HasPremiumAccessQuery.cs index f4ec353539fd..c047012380eb 100644 --- a/src/Core/Auth/UserFeatures/PremiumAccess/PremiumAccessQuery.cs +++ b/src/Core/Billing/Premium/Queries/HasPremiumAccessQuery.cs @@ -1,27 +1,27 @@ using Bit.Core.Repositories; -namespace Bit.Core.Auth.UserFeatures.PremiumAccess; +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 PremiumAccessQuery : IPremiumAccessQuery +public class HasPremiumAccessQuery : IHasPremiumAccessQuery { private readonly IUserRepository _userRepository; - public PremiumAccessQuery(IUserRepository userRepository) + public HasPremiumAccessQuery(IUserRepository userRepository) { _userRepository = userRepository; } - public async Task CanAccessPremiumAsync(Guid userId) + public async Task HasPremiumAccessAsync(Guid userId) { var user = await _userRepository.GetCalculatedPremiumAsync(userId); return user?.HasPremiumAccess ?? false; } - public async Task> CanAccessPremiumAsync(IEnumerable userIds) + public async Task> HasPremiumAccessAsync(IEnumerable userIds) { var usersWithPremium = await _userRepository.GetManyWithCalculatedPremiumAsync(userIds); return usersWithPremium.ToDictionary(u => u.Id, u => u.HasPremiumAccess); @@ -40,3 +40,5 @@ public async Task HasPremiumFromOrganizationAsync(Guid userId) } } + + diff --git a/src/Core/Auth/UserFeatures/PremiumAccess/IPremiumAccessQuery.cs b/src/Core/Billing/Premium/Queries/IHasPremiumAccessQuery.cs similarity index 89% rename from src/Core/Auth/UserFeatures/PremiumAccess/IPremiumAccessQuery.cs rename to src/Core/Billing/Premium/Queries/IHasPremiumAccessQuery.cs index 01cea983a157..95c7019b361b 100644 --- a/src/Core/Auth/UserFeatures/PremiumAccess/IPremiumAccessQuery.cs +++ b/src/Core/Billing/Premium/Queries/IHasPremiumAccessQuery.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Auth.UserFeatures.PremiumAccess; +namespace Bit.Core.Billing.Premium.Queries; /// /// Query for checking premium access status for users. @@ -10,7 +10,7 @@ /// personal subscription status. Use these methods to check actual premium feature access. /// /// -public interface IPremiumAccessQuery +public interface IHasPremiumAccessQuery { /// /// Checks if a user has access to premium features (personal subscription or organization). @@ -18,7 +18,7 @@ public interface IPremiumAccessQuery /// /// The user ID to check for premium access /// True if user can access premium features; false otherwise - Task CanAccessPremiumAsync(Guid userId); + Task HasPremiumAccessAsync(Guid userId); /// /// Checks if a user has access to premium features through organization membership only. @@ -34,6 +34,8 @@ public interface IPremiumAccessQuery /// /// The user IDs to check for premium access /// Dictionary mapping user IDs to their premium access status (personal or through organization) - Task> CanAccessPremiumAsync(IEnumerable userIds); + Task> HasPremiumAccessAsync(IEnumerable userIds); } + + diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index 00d39757c249..c324a8b1d93f 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -64,7 +64,7 @@ Task GenerateLicenseAsync(User user, SubscriptionInfo subscriptionI /// /// user being acted on /// true if they can access premium; false otherwise. - [Obsolete("Use IPremiumAccessQuery.CanAccessPremiumAsync instead. This method is only used when feature flag 'PremiumAccessQuery' is disabled.")] + [Obsolete("Use IHasPremiumAccessQuery.HasPremiumAccessAsync instead. This method is only used when feature flag 'PremiumAccessQuery' is disabled.")] Task CanAccessPremium(User user); /// @@ -75,7 +75,7 @@ Task GenerateLicenseAsync(User user, SubscriptionInfo subscriptionI /// /// /// - [Obsolete("Use IPremiumAccessQuery.HasPremiumFromOrganizationAsync instead. This method is only used when feature flag 'PremiumAccessQuery' is disabled.")] + [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 4a703a736df0..0856c3377fbc 100644 --- a/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs +++ b/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs @@ -1,7 +1,7 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; -using Bit.Core.Auth.UserFeatures.PremiumAccess; 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; @@ -435,8 +435,8 @@ public async Task TwoFactorIsEnabledVNextAsync_WithProviderTypeNotRequiringPremi .GetManyAsync(Arg.Is>(i => i.All(userIds.Contains))) .Returns(users); - sutProvider.GetDependency() - .CanAccessPremiumAsync(userIds) + sutProvider.GetDependency() + .HasPremiumAccessAsync(userIds) .Returns(premiumStatus); // Act @@ -461,8 +461,8 @@ public async Task TwoFactorIsEnabledVNextAsync_DatabaseReturnsEmpty_ResultEmpty( .GetManyAsync(Arg.Any>()) .Returns([]); - sutProvider.GetDependency() - .CanAccessPremiumAsync(Arg.Any>()) + sutProvider.GetDependency() + .HasPremiumAccessAsync(Arg.Any>()) .Returns(new Dictionary()); // Act @@ -510,8 +510,8 @@ public async Task TwoFactorIsEnabledVNextAsync_WithNoTwoFactorEnabled_ReturnsAll .GetManyAsync(Arg.Is>(i => i.All(userIds.Contains))) .Returns(users); - sutProvider.GetDependency() - .CanAccessPremiumAsync(userIds) + sutProvider.GetDependency() + .HasPremiumAccessAsync(userIds) .Returns(premiumStatus); // Act @@ -555,8 +555,8 @@ public async Task TwoFactorIsEnabledVNextAsync_WithProviderTypeRequiringPremium_ .GetManyAsync(Arg.Is>(i => i.All(userIds.Contains))) .Returns(users); - sutProvider.GetDependency() - .CanAccessPremiumAsync(userIds) + sutProvider.GetDependency() + .HasPremiumAccessAsync(userIds) .Returns(premiumStatus); // Act @@ -593,8 +593,8 @@ public async Task TwoFactorIsEnabledVNextAsync_WithNullOrEmptyTwoFactorProviders .GetManyAsync(Arg.Is>(i => i.All(userIds.Contains))) .Returns(users); - sutProvider.GetDependency() - .CanAccessPremiumAsync(userIds) + sutProvider.GetDependency() + .HasPremiumAccessAsync(userIds) .Returns(premiumStatus); // Act @@ -661,9 +661,9 @@ public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithProviderTypeNotReq Assert.True(result); // Should not need to check premium access for free providers - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .CanAccessPremiumAsync(Arg.Any()); + .HasPremiumAccessAsync(Arg.Any()); } [Theory] @@ -686,9 +686,9 @@ public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithNoTwoFactorEnabled // Assert Assert.False(result); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .CanAccessPremiumAsync(Arg.Any()); + .HasPremiumAccessAsync(Arg.Any()); } [Theory] @@ -708,8 +708,8 @@ public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithProviderTypeRequir user.Premium = false; user.SetTwoFactorProviders(twoFactorProviders); - sutProvider.GetDependency() - .CanAccessPremiumAsync(user.Id) + sutProvider.GetDependency() + .HasPremiumAccessAsync(user.Id) .Returns(false); // Act @@ -718,9 +718,9 @@ public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithProviderTypeRequir // Assert Assert.False(result); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) - .CanAccessPremiumAsync(user.Id); + .HasPremiumAccessAsync(user.Id); } [Theory] @@ -740,8 +740,8 @@ public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithProviderTypeRequir user.Premium = true; user.SetTwoFactorProviders(twoFactorProviders); - sutProvider.GetDependency() - .CanAccessPremiumAsync(user.Id) + sutProvider.GetDependency() + .HasPremiumAccessAsync(user.Id) .Returns(true); // Act @@ -750,9 +750,9 @@ public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithProviderTypeRequir // Assert Assert.True(result); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) - .CanAccessPremiumAsync(user.Id); + .HasPremiumAccessAsync(user.Id); } [Theory] @@ -772,8 +772,8 @@ public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithProviderTypeRequir user.Premium = false; user.SetTwoFactorProviders(twoFactorProviders); - sutProvider.GetDependency() - .CanAccessPremiumAsync(user.Id) + sutProvider.GetDependency() + .HasPremiumAccessAsync(user.Id) .Returns(true); // Has premium from org // Act @@ -782,9 +782,9 @@ public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithProviderTypeRequir // Assert Assert.True(result); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) - .CanAccessPremiumAsync(user.Id); + .HasPremiumAccessAsync(user.Id); } [Theory] @@ -801,9 +801,9 @@ public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithNullTwoFactorProvi // Assert Assert.False(result); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .CanAccessPremiumAsync(Arg.Any()); + .HasPremiumAccessAsync(Arg.Any()); } private class TestTwoFactorProviderUser : ITwoFactorProvidersUser diff --git a/test/Core.Test/Auth/UserFeatures/PremiumAccess/PremiumAccessQueryTests.cs b/test/Core.Test/Billing/Premium/Queries/HasPremiumAccessQueryTests.cs similarity index 77% rename from test/Core.Test/Auth/UserFeatures/PremiumAccess/PremiumAccessQueryTests.cs rename to test/Core.Test/Billing/Premium/Queries/HasPremiumAccessQueryTests.cs index cf6e154fc596..c77a354589cc 100644 --- a/test/Core.Test/Auth/UserFeatures/PremiumAccess/PremiumAccessQueryTests.cs +++ b/test/Core.Test/Billing/Premium/Queries/HasPremiumAccessQueryTests.cs @@ -1,4 +1,4 @@ -using Bit.Core.Auth.UserFeatures.PremiumAccess; +using Bit.Core.Billing.Premium.Queries; using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Test.Common.AutoFixture; @@ -6,15 +6,15 @@ using NSubstitute; using Xunit; -namespace Bit.Core.Test.Auth.UserFeatures.PremiumAccess; +namespace Bit.Core.Test.Billing.Premium.Queries; [SutProviderCustomize] -public class PremiumAccessQueryTests +public class HasPremiumAccessQueryTests { [Theory, BitAutoData] - public async Task CanAccessPremiumAsync_WhenUserHasPersonalPremium_ReturnsTrue( + public async Task HasPremiumAccessAsync_WhenUserHasPersonalPremium_ReturnsTrue( UserWithCalculatedPremium user, - SutProvider sutProvider) + SutProvider sutProvider) { // Arrange user.Premium = true; @@ -25,16 +25,16 @@ public async Task CanAccessPremiumAsync_WhenUserHasPersonalPremium_ReturnsTrue( .Returns(user); // Act - var result = await sutProvider.Sut.CanAccessPremiumAsync(user.Id); + var result = await sutProvider.Sut.HasPremiumAccessAsync(user.Id); // Assert Assert.True(result); } [Theory, BitAutoData] - public async Task CanAccessPremiumAsync_WhenUserHasNoPersonalPremiumButHasOrgPremium_ReturnsTrue( + public async Task HasPremiumAccessAsync_WhenUserHasNoPersonalPremiumButHasOrgPremium_ReturnsTrue( UserWithCalculatedPremium user, - SutProvider sutProvider) + SutProvider sutProvider) { // Arrange user.Premium = false; @@ -45,16 +45,16 @@ public async Task CanAccessPremiumAsync_WhenUserHasNoPersonalPremiumButHasOrgPre .Returns(user); // Act - var result = await sutProvider.Sut.CanAccessPremiumAsync(user.Id); + var result = await sutProvider.Sut.HasPremiumAccessAsync(user.Id); // Assert Assert.True(result); } [Theory, BitAutoData] - public async Task CanAccessPremiumAsync_WhenUserHasNoPersonalPremiumAndNoOrgPremium_ReturnsFalse( + public async Task HasPremiumAccessAsync_WhenUserHasNoPersonalPremiumAndNoOrgPremium_ReturnsFalse( UserWithCalculatedPremium user, - SutProvider sutProvider) + SutProvider sutProvider) { // Arrange user.Premium = false; @@ -65,16 +65,16 @@ public async Task CanAccessPremiumAsync_WhenUserHasNoPersonalPremiumAndNoOrgPrem .Returns(user); // Act - var result = await sutProvider.Sut.CanAccessPremiumAsync(user.Id); + var result = await sutProvider.Sut.HasPremiumAccessAsync(user.Id); // Assert Assert.False(result); } [Theory, BitAutoData] - public async Task CanAccessPremiumAsync_WhenUserNotFound_ReturnsFalse( + public async Task HasPremiumAccessAsync_WhenUserNotFound_ReturnsFalse( Guid userId, - SutProvider sutProvider) + SutProvider sutProvider) { // Arrange sutProvider.GetDependency() @@ -82,7 +82,7 @@ public async Task CanAccessPremiumAsync_WhenUserNotFound_ReturnsFalse( .Returns((UserWithCalculatedPremium)null); // Act - var result = await sutProvider.Sut.CanAccessPremiumAsync(userId); + var result = await sutProvider.Sut.HasPremiumAccessAsync(userId); // Assert Assert.False(result); @@ -91,7 +91,7 @@ public async Task CanAccessPremiumAsync_WhenUserNotFound_ReturnsFalse( [Theory, BitAutoData] public async Task HasPremiumFromOrganizationAsync_WhenUserHasNoOrganizations_ReturnsFalse( UserWithCalculatedPremium user, - SutProvider sutProvider) + SutProvider sutProvider) { // Arrange user.Premium = false; @@ -111,7 +111,7 @@ public async Task HasPremiumFromOrganizationAsync_WhenUserHasNoOrganizations_Ret [Theory, BitAutoData] public async Task HasPremiumFromOrganizationAsync_WhenUserHasPremiumFromOrg_ReturnsTrue( UserWithCalculatedPremium user, - SutProvider sutProvider) + SutProvider sutProvider) { // Arrange user.Premium = false; // No personal premium @@ -131,7 +131,7 @@ public async Task HasPremiumFromOrganizationAsync_WhenUserHasPremiumFromOrg_Retu [Theory, BitAutoData] public async Task HasPremiumFromOrganizationAsync_WhenUserHasOnlyPersonalPremium_ReturnsFalse( UserWithCalculatedPremium user, - SutProvider sutProvider) + SutProvider sutProvider) { // Arrange user.Premium = true; // Has personal premium @@ -151,7 +151,7 @@ public async Task HasPremiumFromOrganizationAsync_WhenUserHasOnlyPersonalPremium [Theory, BitAutoData] public async Task HasPremiumFromOrganizationAsync_WhenUserNotFound_ReturnsFalse( Guid userId, - SutProvider sutProvider) + SutProvider sutProvider) { // Arrange sutProvider.GetDependency() @@ -166,8 +166,8 @@ public async Task HasPremiumFromOrganizationAsync_WhenUserNotFound_ReturnsFalse( } [Theory, BitAutoData] - public async Task CanAccessPremiumAsync_Bulk_WhenEmptyList_ReturnsEmptyDictionary( - SutProvider sutProvider) + public async Task HasPremiumAccessAsync_Bulk_WhenEmptyList_ReturnsEmptyDictionary( + SutProvider sutProvider) { // Arrange var userIds = new List(); @@ -177,16 +177,16 @@ public async Task CanAccessPremiumAsync_Bulk_WhenEmptyList_ReturnsEmptyDictionar .Returns(new List()); // Act - var result = await sutProvider.Sut.CanAccessPremiumAsync(userIds); + var result = await sutProvider.Sut.HasPremiumAccessAsync(userIds); // Assert Assert.Empty(result); } [Theory, BitAutoData] - public async Task CanAccessPremiumAsync_Bulk_ReturnsCorrectStatus( + public async Task HasPremiumAccessAsync_Bulk_ReturnsCorrectStatus( List users, - SutProvider sutProvider) + SutProvider sutProvider) { // Arrange users[0].HasPremiumAccess = true; @@ -200,7 +200,7 @@ public async Task CanAccessPremiumAsync_Bulk_ReturnsCorrectStatus( .Returns(users); // Act - var result = await sutProvider.Sut.CanAccessPremiumAsync(userIds); + var result = await sutProvider.Sut.HasPremiumAccessAsync(userIds); // Assert Assert.Equal(users.Count, result.Count); @@ -209,3 +209,5 @@ public async Task CanAccessPremiumAsync_Bulk_ReturnsCorrectStatus( Assert.True(result[users[2].Id]); } } + +