diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs index c0d302df02..86c94147f4 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Enums; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; @@ -17,26 +18,13 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; ///
  • All organization users are compliant with the Single organization policy
  • ///
  • No provider users exist
  • /// -/// -/// This class also performs side effects when the policy is being enabled or disabled. They are: -/// /// public class AutomaticUserConfirmationPolicyEventHandler( IOrganizationUserRepository organizationUserRepository, - IProviderUserRepository providerUserRepository, - IPolicyRepository policyRepository, - IOrganizationRepository organizationRepository, - TimeProvider timeProvider) - : IPolicyValidator, IPolicyValidationEvent, IOnPolicyPreUpdateEvent, IEnforceDependentPoliciesEvent + IProviderUserRepository providerUserRepository) + : IPolicyValidator, IPolicyValidationEvent, IEnforceDependentPoliciesEvent { public PolicyType Type => PolicyType.AutomaticUserConfirmation; - public async Task ExecutePreUpsertSideEffectAsync(SavePolicyModel policyRequest, Policy? currentPolicy) => - await OnSaveSideEffectsAsync(policyRequest.PolicyUpdate, currentPolicy); - - private const string _singleOrgPolicyNotEnabledErrorMessage = - "The Single organization policy must be enabled before enabling the Automatically confirm invited users policy."; private const string _usersNotCompliantWithSingleOrgErrorMessage = "All organization users must be compliant with the Single organization policy before enabling the Automatically confirm invited users policy. Please remove users who are members of multiple organizations."; @@ -61,27 +49,20 @@ public class AutomaticUserConfirmationPolicyEventHandler( public async Task ValidateAsync(SavePolicyModel savePolicyModel, Policy? currentPolicy) => await ValidateAsync(savePolicyModel.PolicyUpdate, currentPolicy); - public async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) - { - var organization = await organizationRepository.GetByIdAsync(policyUpdate.OrganizationId); - - if (organization is not null) - { - organization.UseAutomaticUserConfirmation = policyUpdate.Enabled; - organization.RevisionDate = timeProvider.GetUtcNow().UtcDateTime; - await organizationRepository.UpsertAsync(organization); - } - } + public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => + Task.CompletedTask; private async Task ValidateEnablingPolicyAsync(Guid organizationId) { - var singleOrgValidationError = await ValidateSingleOrgPolicyComplianceAsync(organizationId); + var organizationUsers = await organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId); + + var singleOrgValidationError = await ValidateUserComplianceWithSingleOrgAsync(organizationId, organizationUsers); if (!string.IsNullOrWhiteSpace(singleOrgValidationError)) { return singleOrgValidationError; } - var providerValidationError = await ValidateNoProviderUsersAsync(organizationId); + var providerValidationError = await ValidateNoProviderUsersAsync(organizationUsers); if (!string.IsNullOrWhiteSpace(providerValidationError)) { return providerValidationError; @@ -90,42 +71,24 @@ public class AutomaticUserConfirmationPolicyEventHandler( return string.Empty; } - private async Task ValidateSingleOrgPolicyComplianceAsync(Guid organizationId) + private async Task ValidateUserComplianceWithSingleOrgAsync(Guid organizationId, + ICollection organizationUsers) { - var singleOrgPolicy = await policyRepository.GetByOrganizationIdTypeAsync(organizationId, PolicyType.SingleOrg); - if (singleOrgPolicy is not { Enabled: true }) - { - return _singleOrgPolicyNotEnabledErrorMessage; - } - - return await ValidateUserComplianceWithSingleOrgAsync(organizationId); - } - - private async Task ValidateUserComplianceWithSingleOrgAsync(Guid organizationId) - { - var organizationUsers = (await organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId)) - .Where(ou => ou.Status != OrganizationUserStatusType.Invited && - ou.Status != OrganizationUserStatusType.Revoked && - ou.UserId.HasValue) - .ToList(); - - if (organizationUsers.Count == 0) - { - return string.Empty; - } - var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync( organizationUsers.Select(ou => ou.UserId!.Value))) - .Any(uo => uo.OrganizationId != organizationId && - uo.Status != OrganizationUserStatusType.Invited); + .Any(uo => uo.OrganizationId != organizationId + && uo.Status != OrganizationUserStatusType.Invited); return hasNonCompliantUser ? _usersNotCompliantWithSingleOrgErrorMessage : string.Empty; } - private async Task ValidateNoProviderUsersAsync(Guid organizationId) + private async Task ValidateNoProviderUsersAsync(ICollection organizationUsers) { - var providerUsers = await providerUserRepository.GetManyByOrganizationAsync(organizationId); + var userIds = organizationUsers.Where(x => x.UserId is not null) + .Select(x => x.UserId!.Value); - return providerUsers.Count > 0 ? _providerUsersExistErrorMessage : string.Empty; + return (await providerUserRepository.GetManyByManyUsersAsync(userIds)).Count != 0 + ? _providerUsersExistErrorMessage + : string.Empty; } } diff --git a/src/Core/AdminConsole/Repositories/IProviderUserRepository.cs b/src/Core/AdminConsole/Repositories/IProviderUserRepository.cs index 7bc4125778..0a640b7530 100644 --- a/src/Core/AdminConsole/Repositories/IProviderUserRepository.cs +++ b/src/Core/AdminConsole/Repositories/IProviderUserRepository.cs @@ -12,6 +12,7 @@ public interface IProviderUserRepository : IRepository Task GetCountByProviderAsync(Guid providerId, string email, bool onlyRegisteredUsers); Task> GetManyAsync(IEnumerable ids); Task> GetManyByUserAsync(Guid userId); + Task> GetManyByManyUsersAsync(IEnumerable userIds); Task GetByProviderUserAsync(Guid providerId, Guid userId); Task> GetManyByProviderAsync(Guid providerId, ProviderUserType? type = null); Task> GetManyDetailsByProviderAsync(Guid providerId, ProviderUserStatusType? status = null); diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index a1d4af464a..b0d7da05a2 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -732,7 +732,7 @@ public class GlobalSettings : IGlobalSettings public class ExtendedCacheSettings { public bool EnableDistributedCache { get; set; } = true; - public bool UseSharedRedisCache { get; set; } = true; + public bool UseSharedDistributedCache { get; set; } = true; public IConnectionStringSettings Redis { get; set; } = new ConnectionStringSettings(); public TimeSpan Duration { get; set; } = TimeSpan.FromMinutes(30); public bool IsFailSafeEnabled { get; set; } = true; diff --git a/src/Core/Utilities/CACHING.md b/src/Core/Utilities/CACHING.md index d838896cbf..d80e629bdd 100644 --- a/src/Core/Utilities/CACHING.md +++ b/src/Core/Utilities/CACHING.md @@ -140,7 +140,7 @@ services.AddExtendedCache("MyFeatureCache", globalSettings, new GlobalSettings.E // Option 4: Isolated Redis for specialized features services.AddExtendedCache("SpecializedCache", globalSettings, new GlobalSettings.ExtendedCacheSettings { - UseSharedRedisCache = false, + UseSharedDistributedCache = false, Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "localhost:6379,ssl=false" diff --git a/src/Core/Utilities/ExtendedCacheServiceCollectionExtensions.cs b/src/Core/Utilities/ExtendedCacheServiceCollectionExtensions.cs index a928240fd7..f287f64e54 100644 --- a/src/Core/Utilities/ExtendedCacheServiceCollectionExtensions.cs +++ b/src/Core/Utilities/ExtendedCacheServiceCollectionExtensions.cs @@ -18,9 +18,12 @@ public static class ExtendedCacheServiceCollectionExtensions /// Adds a new, named Fusion Cache to the service /// collection. If an existing cache of the same name is found, it will do nothing.
    ///
    - /// Note: When re-using the existing Redis cache, it is expected to call this method after calling - /// services.AddDistributedCache(globalSettings)
    This ensures that DI correctly finds, - /// configures, and re-uses all the shared Redis architecture. + /// Note: When re-using an existing distributed cache, it is expected to call this method after calling + /// services.AddDistributedCache(globalSettings)
    This ensures that DI correctly finds + /// and re-uses the shared distributed cache infrastructure.
    + ///
    + /// Backplane: Cross-instance cache invalidation is only available when using Redis. + /// Non-Redis distributed caches operate with eventual consistency across multiple instances. /// public static IServiceCollection AddExtendedCache( this IServiceCollection services, @@ -72,12 +75,21 @@ public static class ExtendedCacheServiceCollectionExtensions if (!settings.EnableDistributedCache) return services; - if (settings.UseSharedRedisCache) + if (settings.UseSharedDistributedCache) { - // Using Shared Redis, TryAdd and reuse all pieces (multiplexer, distributed cache and backplane) - if (!CoreHelpers.SettingHasValue(globalSettings.DistributedCache.Redis.ConnectionString)) + { + // Using Shared Non-Redis Distributed Cache: + // 1. Assume IDistributedCache is already registered (e.g., Cosmos, SQL Server) + // 2. Backplane not supported (Redis-only feature, requires pub/sub) + + fusionCacheBuilder + .TryWithRegisteredDistributedCache(); + return services; + } + + // Using Shared Redis, TryAdd and reuse all pieces (multiplexer, distributed cache and backplane) services.TryAddSingleton(sp => CreateConnectionMultiplexer(sp, cacheName, globalSettings.DistributedCache.Redis.ConnectionString)); @@ -92,13 +104,13 @@ public static class ExtendedCacheServiceCollectionExtensions }); services.TryAddSingleton(sp => + { + var mux = sp.GetRequiredService(); + return new RedisBackplane(new RedisBackplaneOptions { - var mux = sp.GetRequiredService(); - return new RedisBackplane(new RedisBackplaneOptions - { - ConnectionMultiplexerFactory = () => Task.FromResult(mux) - }); + ConnectionMultiplexerFactory = () => Task.FromResult(mux) }); + }); fusionCacheBuilder .WithRegisteredDistributedCache() @@ -107,10 +119,21 @@ public static class ExtendedCacheServiceCollectionExtensions return services; } - // Using keyed Redis / Distributed Cache. Create all pieces as keyed services. + // Using keyed Distributed Cache. Create/Reuse all pieces as keyed services. if (!CoreHelpers.SettingHasValue(settings.Redis.ConnectionString)) + { + // Using Keyed Non-Redis Distributed Cache: + // 1. Assume IDistributedCache (e.g., Cosmos, SQL Server) is already registered with cacheName as key + // 2. Backplane not supported (Redis-only feature, requires pub/sub) + + fusionCacheBuilder + .TryWithRegisteredKeyedDistributedCache(serviceKey: cacheName); + return services; + } + + // Using Keyed Redis: TryAdd and reuse all pieces (multiplexer, distributed cache and backplane) services.TryAddKeyedSingleton( cacheName, diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/ProviderUserRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/ProviderUserRepository.cs index 467857612f..c05ff040e5 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/ProviderUserRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/ProviderUserRepository.cs @@ -61,6 +61,18 @@ public class ProviderUserRepository : Repository, IProviderU } } + public async Task> GetManyByManyUsersAsync(IEnumerable userIds) + { + await using var connection = new SqlConnection(ConnectionString); + + var results = await connection.QueryAsync( + "[dbo].[ProviderUser_ReadManyByManyUserIds]", + new { UserIds = userIds.ToGuidIdArrayTVP() }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + public async Task GetByProviderUserAsync(Guid providerId, Guid userId) { using (var connection = new SqlConnection(ConnectionString)) diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/ProviderUserRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/ProviderUserRepository.cs index 5474e3e217..8f9a38f9b6 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/ProviderUserRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/ProviderUserRepository.cs @@ -96,6 +96,20 @@ public class ProviderUserRepository : return await query.ToArrayAsync(); } } + + public async Task> GetManyByManyUsersAsync(IEnumerable userIds) + { + await using var scope = ServiceScopeFactory.CreateAsyncScope(); + + var dbContext = GetDatabaseContext(scope); + + var query = from pu in dbContext.ProviderUsers + where pu.UserId != null && userIds.Contains(pu.UserId.Value) + select pu; + + return await query.ToArrayAsync(); + } + public async Task GetByProviderUserAsync(Guid providerId, Guid userId) { using (var scope = ServiceScopeFactory.CreateScope()) diff --git a/src/Sql/dbo/Stored Procedures/ProviderUser_ReadManyByManyUserIds.sql b/src/Sql/dbo/Stored Procedures/ProviderUser_ReadManyByManyUserIds.sql new file mode 100644 index 0000000000..4fe8d153e4 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/ProviderUser_ReadManyByManyUserIds.sql @@ -0,0 +1,13 @@ +CREATE PROCEDURE [dbo].[ProviderUser_ReadManyByManyUserIds] + @UserIds AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + SELECT + [pu].* + FROM + [dbo].[ProviderUserView] AS [pu] + INNER JOIN + @UserIds [u] ON [u].[Id] = [pu].[UserId] +END diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/GroupsControllerPerformanceTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/GroupsControllerPerformanceTests.cs new file mode 100644 index 0000000000..71c6bf104c --- /dev/null +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/GroupsControllerPerformanceTests.cs @@ -0,0 +1,63 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Bit.Api.AdminConsole.Models.Request; +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Api.Models.Request; +using Bit.Seeder.Recipes; +using Xunit; +using Xunit.Abstractions; + +namespace Bit.Api.IntegrationTest.AdminConsole.Controllers; + +public class GroupsControllerPerformanceTests(ITestOutputHelper testOutputHelper) +{ + /// + /// Tests PUT /organizations/{orgId}/groups/{id} + /// + [Theory(Skip = "Performance test")] + [InlineData(10, 5)] + //[InlineData(100, 10)] + //[InlineData(1000, 20)] + public async Task UpdateGroup_WithUsersAndCollections(int userCount, int collectionCount) + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + var collectionsSeeder = new CollectionsRecipe(db); + var groupsSeeder = new GroupsRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount); + + var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList(); + var collectionIds = collectionsSeeder.AddToOrganization(orgId, collectionCount, orgUserIds, 0); + var groupIds = groupsSeeder.AddToOrganization(orgId, 1, orgUserIds, 0); + + var groupId = groupIds.First(); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var updateRequest = new GroupRequestModel + { + Name = "Updated Group Name", + Collections = collectionIds.Select(c => new SelectionReadOnlyRequestModel { Id = c, ReadOnly = false, HidePasswords = false, Manage = false }), + Users = orgUserIds + }; + + var requestContent = new StringContent(JsonSerializer.Serialize(updateRequest), Encoding.UTF8, "application/json"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.PutAsync($"/organizations/{orgId}/groups/{groupId}", requestContent); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"PUT /organizations/{{orgId}}/groups/{{id}} - Users: {orgUserIds.Count}; Collections: {collectionIds.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +} diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs index d77a41f52e..fc64930777 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs @@ -1,39 +1,593 @@ using System.Net; -using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Api.Models.Request; +using Bit.Core.Enums; +using Bit.Core.Models.Data; using Bit.Seeder.Recipes; using Xunit; using Xunit.Abstractions; namespace Bit.Api.IntegrationTest.AdminConsole.Controllers; -public class OrganizationUsersControllerPerformanceTest(ITestOutputHelper testOutputHelper) +public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testOutputHelper) { + /// + /// Tests GET /organizations/{orgId}/users?includeCollections=true + /// [Theory(Skip = "Performance test")] - [InlineData(100)] - [InlineData(60000)] - public async Task GetAsync(int seats) + [InlineData(10)] + //[InlineData(100)] + //[InlineData(1000)] + public async Task GetAllUsers_WithCollections(int seats) { await using var factory = new SqlServerApiApplicationFactory(); var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var seeder = new OrganizationWithUsersRecipe(db); + var orgSeeder = new OrganizationWithUsersRecipe(db); + var collectionsSeeder = new CollectionsRecipe(db); + var groupsSeeder = new GroupsRecipe(db); - var orgId = seeder.Seed("Org", seats, "large.test"); + var domain = OrganizationTestHelpers.GenerateRandomDomain(); - var tokens = await factory.LoginAsync("admin@large.test", "c55hlJ/cfdvTd4awTXUqow6X3cOQCfGwn11o3HblnPs="); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Token); + var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: seats); + + var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList(); + collectionsSeeder.AddToOrganization(orgId, 10, orgUserIds); + groupsSeeder.AddToOrganization(orgId, 5, orgUserIds); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); var stopwatch = System.Diagnostics.Stopwatch.StartNew(); var response = await client.GetAsync($"/organizations/{orgId}/users?includeCollections=true"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var result = await response.Content.ReadAsStringAsync(); - Assert.NotEmpty(result); + stopwatch.Stop(); + testOutputHelper.WriteLine($"GET /users - Seats: {seats}; Request duration: {stopwatch.ElapsedMilliseconds} ms"); + } + + /// + /// Tests GET /organizations/{orgId}/users/mini-details + /// + [Theory(Skip = "Performance test")] + [InlineData(10)] + //[InlineData(100)] + //[InlineData(1000)] + public async Task GetAllUsers_MiniDetails(int seats) + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + var collectionsSeeder = new CollectionsRecipe(db); + var groupsSeeder = new GroupsRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: seats); + + var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList(); + collectionsSeeder.AddToOrganization(orgId, 10, orgUserIds); + groupsSeeder.AddToOrganization(orgId, 5, orgUserIds); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.GetAsync($"/organizations/{orgId}/users/mini-details"); stopwatch.Stop(); - testOutputHelper.WriteLine($"Seed: {seats}; Request duration: {stopwatch.ElapsedMilliseconds} ms"); + + testOutputHelper.WriteLine($"GET /users/mini-details - Seats: {seats}; Request duration: {stopwatch.ElapsedMilliseconds} ms"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + /// + /// Tests GET /organizations/{orgId}/users/{id}?includeGroups=true + /// + [Fact(Skip = "Performance test")] + public async Task GetSingleUser_WithGroups() + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + var groupsSeeder = new GroupsRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1); + + var orgUserId = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).FirstOrDefault(); + groupsSeeder.AddToOrganization(orgId, 2, [orgUserId]); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.GetAsync($"/organizations/{orgId}/users/{orgUserId}?includeGroups=true"); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"GET /users/{{id}} - Request duration: {stopwatch.ElapsedMilliseconds} ms"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + /// + /// Tests GET /organizations/{orgId}/users/{id}/reset-password-details + /// + [Fact(Skip = "Performance test")] + public async Task GetResetPasswordDetails_ForSingleUser() + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1); + + var orgUserId = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).FirstOrDefault(); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.GetAsync($"/organizations/{orgId}/users/{orgUserId}/reset-password-details"); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"GET /users/{{id}}/reset-password-details - Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + /// + /// Tests POST /organizations/{orgId}/users/confirm + /// + [Theory(Skip = "Performance test")] + [InlineData(10)] + //[InlineData(100)] + //[InlineData(1000)] + public async Task BulkConfirmUsers(int userCount) + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed( + name: "Org", + domain: domain, + users: userCount, + usersStatus: OrganizationUserStatusType.Accepted); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var acceptedUserIds = db.OrganizationUsers + .Where(ou => ou.OrganizationId == orgId && ou.Status == OrganizationUserStatusType.Accepted) + .Select(ou => ou.Id) + .ToList(); + + var confirmRequest = new OrganizationUserBulkConfirmRequestModel + { + Keys = acceptedUserIds.Select(id => new OrganizationUserBulkConfirmRequestModelEntry { Id = id, Key = "test-key-" + id }), + DefaultUserCollectionName = "2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk=" + }; + + var requestContent = new StringContent(JsonSerializer.Serialize(confirmRequest), Encoding.UTF8, "application/json"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.PostAsync($"/organizations/{orgId}/users/confirm", requestContent); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"POST /users/confirm - Users: {acceptedUserIds.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.True(response.IsSuccessStatusCode); + } + + /// + /// Tests POST /organizations/{orgId}/users/remove + /// + [Theory(Skip = "Performance test")] + [InlineData(10)] + //[InlineData(100)] + //[InlineData(1000)] + public async Task BulkRemoveUsers(int userCount) + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var usersToRemove = db.OrganizationUsers + .Where(ou => ou.OrganizationId == orgId && ou.Type == OrganizationUserType.User) + .Select(ou => ou.Id) + .ToList(); + + var removeRequest = new OrganizationUserBulkRequestModel { Ids = usersToRemove }; + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var requestContent = new StringContent(JsonSerializer.Serialize(removeRequest), Encoding.UTF8, "application/json"); + + var response = await client.PostAsync($"/organizations/{orgId}/users/remove", requestContent); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"POST /users/remove - Users: {usersToRemove.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.True(response.IsSuccessStatusCode); + } + + /// + /// Tests PUT /organizations/{orgId}/users/revoke + /// + [Theory(Skip = "Performance test")] + [InlineData(10)] + //[InlineData(100)] + //[InlineData(1000)] + public async Task BulkRevokeUsers(int userCount) + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed( + name: "Org", + domain: domain, + users: userCount, + usersStatus: OrganizationUserStatusType.Confirmed); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var usersToRevoke = db.OrganizationUsers + .Where(ou => ou.OrganizationId == orgId && ou.Type == OrganizationUserType.User) + .Select(ou => ou.Id) + .ToList(); + + var revokeRequest = new OrganizationUserBulkRequestModel { Ids = usersToRevoke }; + + var requestContent = new StringContent(JsonSerializer.Serialize(revokeRequest), Encoding.UTF8, "application/json"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.PutAsync($"/organizations/{orgId}/users/revoke", requestContent); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"PUT /users/revoke - Users: {usersToRevoke.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.True(response.IsSuccessStatusCode); + } + + /// + /// Tests PUT /organizations/{orgId}/users/restore + /// + [Theory(Skip = "Performance test")] + [InlineData(10)] + //[InlineData(100)] + //[InlineData(1000)] + public async Task BulkRestoreUsers(int userCount) + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed( + name: "Org", + domain: domain, + users: userCount, + usersStatus: OrganizationUserStatusType.Revoked); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var usersToRestore = db.OrganizationUsers + .Where(ou => ou.OrganizationId == orgId && ou.Type == OrganizationUserType.User) + .Select(ou => ou.Id) + .ToList(); + + var restoreRequest = new OrganizationUserBulkRequestModel { Ids = usersToRestore }; + + var requestContent = new StringContent(JsonSerializer.Serialize(restoreRequest), Encoding.UTF8, "application/json"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.PutAsync($"/organizations/{orgId}/users/restore", requestContent); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"PUT /users/restore - Users: {usersToRestore.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.True(response.IsSuccessStatusCode); + } + + /// + /// Tests POST /organizations/{orgId}/users/delete-account + /// + [Theory(Skip = "Performance test")] + [InlineData(10)] + //[InlineData(100)] + //[InlineData(1000)] + public async Task BulkDeleteAccounts(int userCount) + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + var domainSeeder = new OrganizationDomainRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + + var orgId = orgSeeder.Seed( + name: "Org", + domain: domain, + users: userCount, + usersStatus: OrganizationUserStatusType.Confirmed); + + domainSeeder.AddVerifiedDomainToOrganization(orgId, domain); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var usersToDelete = db.OrganizationUsers + .Where(ou => ou.OrganizationId == orgId && ou.Type == OrganizationUserType.User) + .Select(ou => ou.Id) + .ToList(); + + var deleteRequest = new OrganizationUserBulkRequestModel { Ids = usersToDelete }; + + var requestContent = new StringContent(JsonSerializer.Serialize(deleteRequest), Encoding.UTF8, "application/json"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.PostAsync($"/organizations/{orgId}/users/delete-account", requestContent); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"POST /users/delete-account - Users: {usersToDelete.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.True(response.IsSuccessStatusCode); + } + + /// + /// Tests PUT /organizations/{orgId}/users/{id} + /// + [Fact(Skip = "Performance test")] + public async Task UpdateSingleUser_WithCollectionsAndGroups() + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + var collectionsSeeder = new CollectionsRecipe(db); + var groupsSeeder = new GroupsRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1); + + var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList(); + var collectionIds = collectionsSeeder.AddToOrganization(orgId, 3, orgUserIds, 0); + var groupIds = groupsSeeder.AddToOrganization(orgId, 2, orgUserIds, 0); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var userToUpdate = db.OrganizationUsers + .FirstOrDefault(ou => ou.OrganizationId == orgId && ou.Type == OrganizationUserType.User); + + var updateRequest = new OrganizationUserUpdateRequestModel + { + Type = OrganizationUserType.Custom, + Collections = collectionIds.Select(c => new SelectionReadOnlyRequestModel { Id = c, ReadOnly = false, HidePasswords = false, Manage = false }), + Groups = groupIds, + AccessSecretsManager = false, + Permissions = new Permissions { AccessEventLogs = true } + }; + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.PutAsync($"/organizations/{orgId}/users/{userToUpdate.Id}", + new StringContent(JsonSerializer.Serialize(updateRequest), Encoding.UTF8, "application/json")); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"PUT /users/{{id}} - Collections: {collectionIds.Count}; Groups: {groupIds.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.True(response.IsSuccessStatusCode); + } + + /// + /// Tests PUT /organizations/{orgId}/users/enable-secrets-manager + /// + [Theory(Skip = "Performance test")] + [InlineData(10)] + //[InlineData(100)] + //[InlineData(1000)] + public async Task BulkEnableSecretsManager(int userCount) + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var usersToEnable = db.OrganizationUsers + .Where(ou => ou.OrganizationId == orgId && ou.Type == OrganizationUserType.User) + .Select(ou => ou.Id) + .ToList(); + + var enableRequest = new OrganizationUserBulkRequestModel { Ids = usersToEnable }; + + var requestContent = new StringContent(JsonSerializer.Serialize(enableRequest), Encoding.UTF8, "application/json"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.PutAsync($"/organizations/{orgId}/users/enable-secrets-manager", requestContent); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"PUT /users/enable-secrets-manager - Users: {usersToEnable.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.True(response.IsSuccessStatusCode); + } + + /// + /// Tests DELETE /organizations/{orgId}/users/{id}/delete-account + /// + [Fact(Skip = "Performance test")] + public async Task DeleteSingleUserAccount_FromVerifiedDomain() + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + var domainSeeder = new OrganizationDomainRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed( + name: "Org", + domain: domain, + users: 2, + usersStatus: OrganizationUserStatusType.Confirmed); + + domainSeeder.AddVerifiedDomainToOrganization(orgId, domain); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var userToDelete = db.OrganizationUsers + .FirstOrDefault(ou => ou.OrganizationId == orgId && ou.Type == OrganizationUserType.User); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.DeleteAsync($"/organizations/{orgId}/users/{userToDelete.Id}/delete-account"); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"DELETE /users/{{id}}/delete-account - Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + /// + /// Tests POST /organizations/{orgId}/users/invite + /// + [Theory(Skip = "Performance test")] + [InlineData(1)] + //[InlineData(5)] + //[InlineData(20)] + public async Task InviteUsers(int emailCount) + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + var collectionsSeeder = new CollectionsRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1); + + var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList(); + var collectionIds = collectionsSeeder.AddToOrganization(orgId, 2, orgUserIds, 0); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var emails = Enumerable.Range(0, emailCount).Select(i => $"{i:D4}@{domain}").ToArray(); + var inviteRequest = new OrganizationUserInviteRequestModel + { + Emails = emails, + Type = OrganizationUserType.User, + AccessSecretsManager = false, + Collections = Array.Empty(), + Groups = Array.Empty(), + Permissions = null + }; + + var requestContent = new StringContent(JsonSerializer.Serialize(inviteRequest), Encoding.UTF8, "application/json"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.PostAsync($"/organizations/{orgId}/users/invite", requestContent); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"POST /users/invite - Emails: {emails.Length}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + /// + /// Tests POST /organizations/{orgId}/users/reinvite + /// + [Theory(Skip = "Performance test")] + [InlineData(10)] + //[InlineData(100)] + //[InlineData(1000)] + public async Task BulkReinviteUsers(int userCount) + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed( + name: "Org", + domain: domain, + users: userCount, + usersStatus: OrganizationUserStatusType.Invited); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var usersToReinvite = db.OrganizationUsers + .Where(ou => ou.OrganizationId == orgId && ou.Status == OrganizationUserStatusType.Invited) + .Select(ou => ou.Id) + .ToList(); + + var reinviteRequest = new OrganizationUserBulkRequestModel { Ids = usersToReinvite }; + + var requestContent = new StringContent(JsonSerializer.Serialize(reinviteRequest), Encoding.UTF8, "application/json"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.PostAsync($"/organizations/{orgId}/users/reinvite", requestContent); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"POST /users/reinvite - Users: {usersToReinvite.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.True(response.IsSuccessStatusCode); } } diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationsControllerPerformanceTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationsControllerPerformanceTests.cs new file mode 100644 index 0000000000..238a9a5d53 --- /dev/null +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationsControllerPerformanceTests.cs @@ -0,0 +1,163 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.Auth.Models.Request.Accounts; +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Core.AdminConsole.Models.Business.Tokenables; +using Bit.Core.Billing.Enums; +using Bit.Core.Tokens; +using Bit.Seeder.Recipes; +using Xunit; +using Xunit.Abstractions; + +namespace Bit.Api.IntegrationTest.AdminConsole.Controllers; + +public class OrganizationsControllerPerformanceTests(ITestOutputHelper testOutputHelper) +{ + /// + /// Tests DELETE /organizations/{id} with password verification + /// + [Theory(Skip = "Performance test")] + [InlineData(10, 5, 3)] + //[InlineData(100, 20, 10)] + //[InlineData(1000, 50, 25)] + public async Task DeleteOrganization_WithPasswordVerification(int userCount, int collectionCount, int groupCount) + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + var collectionsSeeder = new CollectionsRecipe(db); + var groupsSeeder = new GroupsRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount); + + var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList(); + collectionsSeeder.AddToOrganization(orgId, collectionCount, orgUserIds, 0); + groupsSeeder.AddToOrganization(orgId, groupCount, orgUserIds, 0); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var deleteRequest = new SecretVerificationRequestModel + { + MasterPasswordHash = "c55hlJ/cfdvTd4awTXUqow6X3cOQCfGwn11o3HblnPs=" + }; + + var request = new HttpRequestMessage(HttpMethod.Delete, $"/organizations/{orgId}") + { + Content = new StringContent(JsonSerializer.Serialize(deleteRequest), Encoding.UTF8, "application/json") + }; + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + + var response = await client.SendAsync(request); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"DELETE /organizations/{{id}} - Users: {userCount}; Collections: {collectionCount}; Groups: {groupCount}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + /// + /// Tests POST /organizations/{id}/delete-recover-token with token verification + /// + [Theory(Skip = "Performance test")] + [InlineData(10, 5, 3)] + //[InlineData(100, 20, 10)] + //[InlineData(1000, 50, 25)] + public async Task DeleteOrganization_WithTokenVerification(int userCount, int collectionCount, int groupCount) + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + var collectionsSeeder = new CollectionsRecipe(db); + var groupsSeeder = new GroupsRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount); + + var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList(); + collectionsSeeder.AddToOrganization(orgId, collectionCount, orgUserIds, 0); + groupsSeeder.AddToOrganization(orgId, groupCount, orgUserIds, 0); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var organization = db.Organizations.FirstOrDefault(o => o.Id == orgId); + Assert.NotNull(organization); + + var tokenFactory = factory.GetService>(); + var tokenable = new OrgDeleteTokenable(organization, 24); + var token = tokenFactory.Protect(tokenable); + + var deleteRequest = new OrganizationVerifyDeleteRecoverRequestModel + { + Token = token + }; + + var requestContent = new StringContent(JsonSerializer.Serialize(deleteRequest), Encoding.UTF8, "application/json"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.PostAsync($"/organizations/{orgId}/delete-recover-token", requestContent); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"POST /organizations/{{id}}/delete-recover-token - Users: {userCount}; Collections: {collectionCount}; Groups: {groupCount}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + /// + /// Tests POST /organizations/create-without-payment + /// + [Fact(Skip = "Performance test")] + public async Task CreateOrganization_WithoutPayment() + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var email = $"user@{OrganizationTestHelpers.GenerateRandomDomain()}"; + var masterPasswordHash = "c55hlJ/cfdvTd4awTXUqow6X3cOQCfGwn11o3HblnPs="; + + await factory.LoginWithNewAccount(email, masterPasswordHash); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, email, masterPasswordHash); + + var createRequest = new OrganizationNoPaymentCreateRequest + { + Name = "Test Organization", + BusinessName = "Test Business Name", + BillingEmail = email, + PlanType = PlanType.EnterpriseAnnually, + Key = "2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk=", + AdditionalSeats = 1, + AdditionalStorageGb = 1, + UseSecretsManager = true, + AdditionalSmSeats = 1, + AdditionalServiceAccounts = 2, + MaxAutoscaleSeats = 100, + PremiumAccessAddon = false, + CollectionName = "2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk=" + }; + + var requestContent = new StringContent(JsonSerializer.Serialize(createRequest), Encoding.UTF8, "application/json"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.PostAsync("/organizations/create-without-payment", requestContent); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"POST /organizations/create-without-payment - AdditionalSeats: {createRequest.AdditionalSeats}; AdditionalStorageGb: {createRequest.AdditionalStorageGb}; AdditionalSmSeats: {createRequest.AdditionalSmSeats}; AdditionalServiceAccounts: {createRequest.AdditionalServiceAccounts}; MaxAutoscaleSeats: {createRequest.MaxAutoscaleSeats}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +} diff --git a/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs b/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs index c23ebff736..bcde370b24 100644 --- a/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs +++ b/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs @@ -192,6 +192,15 @@ public static class OrganizationTestHelpers await policyRepository.CreateAsync(policy); } + /// + /// Generates a unique random domain name for testing purposes. + /// + /// A domain string like "a1b2c3d4.com" + public static string GenerateRandomDomain() + { + return $"{Guid.NewGuid().ToString("N").Substring(0, 8)}.com"; + } + /// /// Creates a user account without a Master Password and adds them as a member to the specified organization. /// diff --git a/test/Api.IntegrationTest/Helpers/PerformanceTestHelpers.cs b/test/Api.IntegrationTest/Helpers/PerformanceTestHelpers.cs new file mode 100644 index 0000000000..ca26266dfa --- /dev/null +++ b/test/Api.IntegrationTest/Helpers/PerformanceTestHelpers.cs @@ -0,0 +1,32 @@ +using System.Net.Http.Headers; +using Bit.Api.IntegrationTest.Factories; + +namespace Bit.Api.IntegrationTest.Helpers; + +/// +/// Helper methods for performance tests to reduce code duplication. +/// +public static class PerformanceTestHelpers +{ + /// + /// Standard password hash used across performance tests. + /// + public const string StandardPasswordHash = "c55hlJ/cfdvTd4awTXUqow6X3cOQCfGwn11o3HblnPs="; + + /// + /// Authenticates an HttpClient with a bearer token for the specified user. + /// + /// The application factory to use for login. + /// The HttpClient to authenticate. + /// The user's email address. + /// The user's master password hash. Defaults to StandardPasswordHash. + public static async Task AuthenticateClientAsync( + SqlServerApiApplicationFactory factory, + HttpClient client, + string email, + string? masterPasswordHash = null) + { + var tokens = await factory.LoginAsync(email, masterPasswordHash ?? StandardPasswordHash); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Token); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs index 4781127a3d..3c9fd9a9e9 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs @@ -21,52 +21,23 @@ namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyValidat public class AutomaticUserConfirmationPolicyEventHandlerTests { [Theory, BitAutoData] - public async Task ValidateAsync_EnablingPolicy_SingleOrgNotEnabled_ReturnsError( - [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + public void RequiredPolicies_IncludesSingleOrg( SutProvider sutProvider) { - // Arrange - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) - .Returns((Policy?)null); - // Act - var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + var requiredPolicies = sutProvider.Sut.RequiredPolicies; // Assert - Assert.Contains("Single organization policy must be enabled", result, StringComparison.OrdinalIgnoreCase); - } - - [Theory, BitAutoData] - public async Task ValidateAsync_EnablingPolicy_SingleOrgPolicyDisabled_ReturnsError( - [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - [Policy(PolicyType.SingleOrg, false)] Policy singleOrgPolicy, - SutProvider sutProvider) - { - // Arrange - singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; - - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) - .Returns(singleOrgPolicy); - - // Act - var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); - - // Assert - Assert.Contains("Single organization policy must be enabled", result, StringComparison.OrdinalIgnoreCase); + Assert.Contains(PolicyType.SingleOrg, requiredPolicies); } [Theory, BitAutoData] public async Task ValidateAsync_EnablingPolicy_UsersNotCompliantWithSingleOrg_ReturnsError( [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, Guid nonCompliantUserId, SutProvider sutProvider) { // Arrange - singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; - var orgUser = new OrganizationUserUserDetails { Id = Guid.NewGuid(), @@ -85,10 +56,6 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests Status = OrganizationUserStatusType.Confirmed }; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) - .Returns(singleOrgPolicy); - sutProvider.GetDependency() .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) .Returns([orgUser]); @@ -107,13 +74,10 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests [Theory, BitAutoData] public async Task ValidateAsync_EnablingPolicy_UserWithInvitedStatusInOtherOrg_ValidationPasses( [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, Guid userId, SutProvider sutProvider) { // Arrange - singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; - var orgUser = new OrganizationUserUserDetails { Id = Guid.NewGuid(), @@ -121,7 +85,6 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests Type = OrganizationUserType.User, Status = OrganizationUserStatusType.Confirmed, UserId = userId, - Email = "test@email.com" }; var otherOrgUser = new OrganizationUser @@ -133,10 +96,6 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests Email = orgUser.Email }; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) - .Returns(singleOrgPolicy); - sutProvider.GetDependency() .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) .Returns([orgUser]); @@ -146,7 +105,7 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests .Returns([otherOrgUser]); sutProvider.GetDependency() - .GetManyByOrganizationAsync(policyUpdate.OrganizationId) + .GetManyByManyUsersAsync(Arg.Any>()) .Returns([]); // Act @@ -159,30 +118,37 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests [Theory, BitAutoData] public async Task ValidateAsync_EnablingPolicy_ProviderUsersExist_ReturnsError( [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, + Guid userId, SutProvider sutProvider) { // Arrange - singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; + var orgUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = policyUpdate.OrganizationId, + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Confirmed, + UserId = userId + }; var providerUser = new ProviderUser { Id = Guid.NewGuid(), ProviderId = Guid.NewGuid(), - UserId = Guid.NewGuid(), + UserId = userId, Status = ProviderUserStatusType.Confirmed }; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) - .Returns(singleOrgPolicy); - sutProvider.GetDependency() .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([orgUser]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) .Returns([]); sutProvider.GetDependency() - .GetManyByOrganizationAsync(policyUpdate.OrganizationId) + .GetManyByManyUsersAsync(Arg.Any>()) .Returns([providerUser]); // Act @@ -196,26 +162,18 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests [Theory, BitAutoData] public async Task ValidateAsync_EnablingPolicy_AllValidationsPassed_ReturnsEmptyString( [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, SutProvider sutProvider) { // Arrange - singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; - var orgUser = new OrganizationUserUserDetails { Id = Guid.NewGuid(), OrganizationId = policyUpdate.OrganizationId, Type = OrganizationUserType.User, Status = OrganizationUserStatusType.Confirmed, - UserId = Guid.NewGuid(), - Email = "user@example.com" + UserId = Guid.NewGuid() }; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) - .Returns(singleOrgPolicy); - sutProvider.GetDependency() .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) .Returns([orgUser]); @@ -225,7 +183,7 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests .Returns([]); sutProvider.GetDependency() - .GetManyByOrganizationAsync(policyUpdate.OrganizationId) + .GetManyByManyUsersAsync(Arg.Any>()) .Returns([]); // Act @@ -249,9 +207,10 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests // Assert Assert.True(string.IsNullOrEmpty(result)); - await sutProvider.GetDependency() + + await sutProvider.GetDependency() .DidNotReceive() - .GetByOrganizationIdTypeAsync(Arg.Any(), Arg.Any()); + .GetManyDetailsByOrganizationAsync(Arg.Any()); } [Theory, BitAutoData] @@ -268,21 +227,18 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests // Assert Assert.True(string.IsNullOrEmpty(result)); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceive() - .GetByOrganizationIdTypeAsync(Arg.Any(), Arg.Any()); + .GetManyDetailsByOrganizationAsync(Arg.Any()); } [Theory, BitAutoData] public async Task ValidateAsync_EnablingPolicy_IncludesOwnersAndAdmins_InComplianceCheck( [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, Guid nonCompliantOwnerId, SutProvider sutProvider) { // Arrange - singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; - var ownerUser = new OrganizationUserUserDetails { Id = Guid.NewGuid(), @@ -290,7 +246,6 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests Type = OrganizationUserType.Owner, Status = OrganizationUserStatusType.Confirmed, UserId = nonCompliantOwnerId, - Email = "owner@example.com" }; var otherOrgUser = new OrganizationUser @@ -301,10 +256,6 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests Status = OrganizationUserStatusType.Confirmed }; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) - .Returns(singleOrgPolicy); - sutProvider.GetDependency() .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) .Returns([ownerUser]); @@ -323,12 +274,9 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests [Theory, BitAutoData] public async Task ValidateAsync_EnablingPolicy_InvitedUsersExcluded_FromComplianceCheck( [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, SutProvider sutProvider) { // Arrange - singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; - var invitedUser = new OrganizationUserUserDetails { Id = Guid.NewGuid(), @@ -339,16 +287,12 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests Email = "invited@example.com" }; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) - .Returns(singleOrgPolicy); - sutProvider.GetDependency() .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) .Returns([invitedUser]); sutProvider.GetDependency() - .GetManyByOrganizationAsync(policyUpdate.OrganizationId) + .GetManyByManyUsersAsync(Arg.Any>()) .Returns([]); // Act @@ -359,14 +303,11 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests } [Theory, BitAutoData] - public async Task ValidateAsync_EnablingPolicy_RevokedUsersExcluded_FromComplianceCheck( + public async Task ValidateAsync_EnablingPolicy_RevokedUsersIncluded_InComplianceCheck( [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, SutProvider sutProvider) { // Arrange - singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; - var revokedUser = new OrganizationUserUserDetails { Id = Guid.NewGuid(), @@ -374,38 +315,44 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests Type = OrganizationUserType.User, Status = OrganizationUserStatusType.Revoked, UserId = Guid.NewGuid(), - Email = "revoked@example.com" }; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) - .Returns(singleOrgPolicy); + var additionalOrgUser = new OrganizationUser + { + Id = Guid.NewGuid(), + OrganizationId = Guid.NewGuid(), + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Revoked, + UserId = revokedUser.UserId, + }; - sutProvider.GetDependency() + var orgUserRepository = sutProvider.GetDependency(); + + orgUserRepository .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) .Returns([revokedUser]); + orgUserRepository.GetManyByManyUsersAsync(Arg.Any>()) + .Returns([additionalOrgUser]); + sutProvider.GetDependency() - .GetManyByOrganizationAsync(policyUpdate.OrganizationId) + .GetManyByManyUsersAsync(Arg.Any>()) .Returns([]); // Act var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); // Assert - Assert.True(string.IsNullOrEmpty(result)); + Assert.Contains("compliant with the Single organization policy", result, StringComparison.OrdinalIgnoreCase); } [Theory, BitAutoData] public async Task ValidateAsync_EnablingPolicy_AcceptedUsersIncluded_InComplianceCheck( [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, Guid nonCompliantUserId, SutProvider sutProvider) { // Arrange - singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; - var acceptedUser = new OrganizationUserUserDetails { Id = Guid.NewGuid(), @@ -413,7 +360,6 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests Type = OrganizationUserType.User, Status = OrganizationUserStatusType.Accepted, UserId = nonCompliantUserId, - Email = "accepted@example.com" }; var otherOrgUser = new OrganizationUser @@ -424,10 +370,6 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests Status = OrganizationUserStatusType.Confirmed }; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) - .Returns(singleOrgPolicy); - sutProvider.GetDependency() .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) .Returns([acceptedUser]); @@ -443,186 +385,22 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests Assert.Contains("compliant with the Single organization policy", result, StringComparison.OrdinalIgnoreCase); } - [Theory, BitAutoData] - public async Task ValidateAsync_EnablingPolicy_EmptyOrganization_ReturnsEmptyString( - [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, - SutProvider sutProvider) - { - // Arrange - singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; - - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) - .Returns(singleOrgPolicy); - - sutProvider.GetDependency() - .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) - .Returns([]); - - sutProvider.GetDependency() - .GetManyByOrganizationAsync(policyUpdate.OrganizationId) - .Returns([]); - - // Act - var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); - - // Assert - Assert.True(string.IsNullOrEmpty(result)); - } - [Theory, BitAutoData] public async Task ValidateAsync_WithSavePolicyModel_CallsValidateWithPolicyUpdate( [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, SutProvider sutProvider) { // Arrange - singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; - var savePolicyModel = new SavePolicyModel(policyUpdate); - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) - .Returns(singleOrgPolicy); - sutProvider.GetDependency() .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) .Returns([]); - sutProvider.GetDependency() - .GetManyByOrganizationAsync(policyUpdate.OrganizationId) - .Returns([]); - // Act var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, null); // Assert Assert.True(string.IsNullOrEmpty(result)); } - - [Theory, BitAutoData] - public async Task OnSaveSideEffectsAsync_EnablingPolicy_SetsUseAutomaticUserConfirmationToTrue( - [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - Organization organization, - SutProvider sutProvider) - { - // Arrange - organization.Id = policyUpdate.OrganizationId; - organization.UseAutomaticUserConfirmation = false; - - sutProvider.GetDependency() - .GetByIdAsync(policyUpdate.OrganizationId) - .Returns(organization); - - // Act - await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null); - - // Assert - await sutProvider.GetDependency() - .Received(1) - .UpsertAsync(Arg.Is(o => - o.Id == organization.Id && - o.UseAutomaticUserConfirmation == true && - o.RevisionDate > DateTime.MinValue)); - } - - [Theory, BitAutoData] - public async Task OnSaveSideEffectsAsync_DisablingPolicy_SetsUseAutomaticUserConfirmationToFalse( - [PolicyUpdate(PolicyType.AutomaticUserConfirmation, false)] PolicyUpdate policyUpdate, - Organization organization, - SutProvider sutProvider) - { - // Arrange - organization.Id = policyUpdate.OrganizationId; - organization.UseAutomaticUserConfirmation = true; - - sutProvider.GetDependency() - .GetByIdAsync(policyUpdate.OrganizationId) - .Returns(organization); - - // Act - await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null); - - // Assert - await sutProvider.GetDependency() - .Received(1) - .UpsertAsync(Arg.Is(o => - o.Id == organization.Id && - o.UseAutomaticUserConfirmation == false && - o.RevisionDate > DateTime.MinValue)); - } - - [Theory, BitAutoData] - public async Task OnSaveSideEffectsAsync_OrganizationNotFound_DoesNotThrowException( - [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - SutProvider sutProvider) - { - // Arrange - sutProvider.GetDependency() - .GetByIdAsync(policyUpdate.OrganizationId) - .Returns((Organization?)null); - - // Act - await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null); - - // Assert - await sutProvider.GetDependency() - .DidNotReceive() - .UpsertAsync(Arg.Any()); - } - - [Theory, BitAutoData] - public async Task ExecutePreUpsertSideEffectAsync_CallsOnSaveSideEffectsAsync( - [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - [Policy(PolicyType.AutomaticUserConfirmation)] Policy currentPolicy, - Organization organization, - SutProvider sutProvider) - { - // Arrange - organization.Id = policyUpdate.OrganizationId; - currentPolicy.OrganizationId = policyUpdate.OrganizationId; - - var savePolicyModel = new SavePolicyModel(policyUpdate); - - sutProvider.GetDependency() - .GetByIdAsync(policyUpdate.OrganizationId) - .Returns(organization); - - // Act - await sutProvider.Sut.ExecutePreUpsertSideEffectAsync(savePolicyModel, currentPolicy); - - // Assert - await sutProvider.GetDependency() - .Received(1) - .UpsertAsync(Arg.Is(o => - o.Id == organization.Id && - o.UseAutomaticUserConfirmation == policyUpdate.Enabled)); - } - - [Theory, BitAutoData] - public async Task OnSaveSideEffectsAsync_UpdatesRevisionDate( - [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - Organization organization, - SutProvider sutProvider) - { - // Arrange - organization.Id = policyUpdate.OrganizationId; - var originalRevisionDate = DateTime.UtcNow.AddDays(-1); - organization.RevisionDate = originalRevisionDate; - - sutProvider.GetDependency() - .GetByIdAsync(policyUpdate.OrganizationId) - .Returns(organization); - - // Act - await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null); - - // Assert - await sutProvider.GetDependency() - .Received(1) - .UpsertAsync(Arg.Is(o => - o.Id == organization.Id && - o.RevisionDate > originalRevisionDate)); - } } diff --git a/test/Core.Test/Utilities/ExtendedCacheServiceCollectionExtensionsTests.cs b/test/Core.Test/Utilities/ExtendedCacheServiceCollectionExtensionsTests.cs index 6f7fa4df06..e2cb9d5d52 100644 --- a/test/Core.Test/Utilities/ExtendedCacheServiceCollectionExtensionsTests.cs +++ b/test/Core.Test/Utilities/ExtendedCacheServiceCollectionExtensionsTests.cs @@ -7,6 +7,7 @@ using NSubstitute; using StackExchange.Redis; using Xunit; using ZiggyCreatures.Caching.Fusion; +using ZiggyCreatures.Caching.Fusion.Backplane; namespace Bit.Core.Test.Utilities; @@ -167,7 +168,7 @@ public class ExtendedCacheServiceCollectionExtensionsTests var settings = CreateGlobalSettings(new() { { "GlobalSettings:DistributedCache:Redis:ConnectionString", "localhost:6379" }, - { "GlobalSettings:DistributedCache:DefaultExtendedCache:UseSharedRedisCache", "true" } + { "GlobalSettings:DistributedCache:DefaultExtendedCache:UseSharedDistributedCache", "true" } }); // Provide a multiplexer (shared) @@ -187,7 +188,7 @@ public class ExtendedCacheServiceCollectionExtensionsTests { var settings = new GlobalSettings.ExtendedCacheSettings { - UseSharedRedisCache = false, + UseSharedDistributedCache = false, Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "invalid:9999" } }; @@ -242,7 +243,7 @@ public class ExtendedCacheServiceCollectionExtensionsTests { var settings = new GlobalSettings.ExtendedCacheSettings { - UseSharedRedisCache = false, + UseSharedDistributedCache = false, // No Redis connection string }; @@ -261,13 +262,13 @@ public class ExtendedCacheServiceCollectionExtensionsTests var settingsA = new GlobalSettings.ExtendedCacheSettings { EnableDistributedCache = true, - UseSharedRedisCache = false, + UseSharedDistributedCache = false, Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "localhost:6379" } }; var settingsB = new GlobalSettings.ExtendedCacheSettings { EnableDistributedCache = true, - UseSharedRedisCache = false, + UseSharedDistributedCache = false, Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "localhost:6380" } }; @@ -294,7 +295,7 @@ public class ExtendedCacheServiceCollectionExtensionsTests var settings = new GlobalSettings.ExtendedCacheSettings { - UseSharedRedisCache = false, + UseSharedDistributedCache = false, Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "localhost:6379" } }; @@ -306,6 +307,180 @@ public class ExtendedCacheServiceCollectionExtensionsTests Assert.Same(existingCache, resolved); } + [Fact] + public void AddExtendedCache_SharedNonRedisCache_UsesDistributedCacheWithoutBackplane() + { + var settings = new GlobalSettings.ExtendedCacheSettings + { + UseSharedDistributedCache = true, + EnableDistributedCache = true, + // No Redis.ConnectionString + }; + + // Register non-Redis distributed cache + _services.AddSingleton(Substitute.For()); + + _services.AddExtendedCache(_cacheName, _globalSettings, settings); + + using var provider = _services.BuildServiceProvider(); + var cache = provider.GetRequiredKeyedService(_cacheName); + + Assert.True(cache.HasDistributedCache); + Assert.False(cache.HasBackplane); // No backplane for non-Redis + } + + [Fact] + public void AddExtendedCache_SharedRedisWithMockedMultiplexer_ReusesExistingMultiplexer() + { + // Override GlobalSettings to include Redis connection string + var globalSettings = CreateGlobalSettings(new() + { + { "GlobalSettings:DistributedCache:Redis:ConnectionString", "localhost:6379" } + }); + + // Custom settings for this cache + var settings = new GlobalSettings.ExtendedCacheSettings + { + UseSharedDistributedCache = true, + EnableDistributedCache = true, + }; + + // Pre-register mocked multiplexer (simulates AddDistributedCache already called) + var mockMultiplexer = Substitute.For(); + _services.AddSingleton(mockMultiplexer); + + _services.AddExtendedCache(_cacheName, globalSettings, settings); + + using var provider = _services.BuildServiceProvider(); + var cache = provider.GetRequiredKeyedService(_cacheName); + + Assert.True(cache.HasDistributedCache); + Assert.True(cache.HasBackplane); + + // Verify same multiplexer was reused (TryAdd didn't replace it) + var resolvedMux = provider.GetRequiredService(); + Assert.Same(mockMultiplexer, resolvedMux); + } + + [Fact] + public void AddExtendedCache_KeyedNonRedisCache_UsesKeyedDistributedCacheWithoutBackplane() + { + var settings = new GlobalSettings.ExtendedCacheSettings + { + UseSharedDistributedCache = false, + EnableDistributedCache = true, + // No Redis.ConnectionString + }; + + // Register keyed non-Redis distributed cache + _services.AddKeyedSingleton(_cacheName, Substitute.For()); + + _services.AddExtendedCache(_cacheName, _globalSettings, settings); + + using var provider = _services.BuildServiceProvider(); + var cache = provider.GetRequiredKeyedService(_cacheName); + + Assert.True(cache.HasDistributedCache); + Assert.False(cache.HasBackplane); + } + + [Fact] + public void AddExtendedCache_KeyedRedisWithConnectionString_CreatesIsolatedInfrastructure() + { + var settings = new GlobalSettings.ExtendedCacheSettings + { + UseSharedDistributedCache = false, + EnableDistributedCache = true, + Redis = new GlobalSettings.ConnectionStringSettings + { + ConnectionString = "localhost:6379" + } + }; + + // Pre-register mocked keyed multiplexer to avoid connection attempt + _services.AddKeyedSingleton(_cacheName, Substitute.For()); + + _services.AddExtendedCache(_cacheName, _globalSettings, settings); + + using var provider = _services.BuildServiceProvider(); + var cache = provider.GetRequiredKeyedService(_cacheName); + + Assert.True(cache.HasDistributedCache); + Assert.True(cache.HasBackplane); + + // Verify keyed services exist + var keyedMux = provider.GetRequiredKeyedService(_cacheName); + Assert.NotNull(keyedMux); + var keyedRedis = provider.GetRequiredKeyedService(_cacheName); + Assert.NotNull(keyedRedis); + var keyedBackplane = provider.GetRequiredKeyedService(_cacheName); + Assert.NotNull(keyedBackplane); + } + + [Fact] + public void AddExtendedCache_NoDistributedCacheRegistered_WorksWithMemoryOnly() + { + var settings = new GlobalSettings.ExtendedCacheSettings + { + UseSharedDistributedCache = true, + EnableDistributedCache = true, + // No Redis connection string, no IDistributedCache registered + // This is technically a misconfiguration, but we handle it without failing + }; + + _services.AddExtendedCache(_cacheName, _globalSettings, settings); + + using var provider = _services.BuildServiceProvider(); + var cache = provider.GetRequiredKeyedService(_cacheName); + + Assert.False(cache.HasDistributedCache); + Assert.False(cache.HasBackplane); + + // Verify L1 memory cache still works + cache.Set("key", "value"); + var result = cache.GetOrDefault("key"); + Assert.Equal("value", result); + } + + [Fact] + public void AddExtendedCache_MultipleKeyedCachesWithDifferentTypes_EachHasCorrectConfig() + { + var redisSettings = new GlobalSettings.ExtendedCacheSettings + { + UseSharedDistributedCache = false, + EnableDistributedCache = true, + Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "localhost:6379" } + }; + + var nonRedisSettings = new GlobalSettings.ExtendedCacheSettings + { + UseSharedDistributedCache = false, + EnableDistributedCache = true, + // No Redis connection string + }; + + // Setup Cache1 (Redis) + _services.AddKeyedSingleton("Cache1", Substitute.For()); + _services.AddExtendedCache("Cache1", _globalSettings, redisSettings); + + // Setup Cache2 (non-Redis) + _services.AddKeyedSingleton("Cache2", Substitute.For()); + _services.AddExtendedCache("Cache2", _globalSettings, nonRedisSettings); + + using var provider = _services.BuildServiceProvider(); + + var cache1 = provider.GetRequiredKeyedService("Cache1"); + var cache2 = provider.GetRequiredKeyedService("Cache2"); + + Assert.True(cache1.HasDistributedCache); + Assert.True(cache1.HasBackplane); + + Assert.True(cache2.HasDistributedCache); + Assert.False(cache2.HasBackplane); + + Assert.NotSame(cache1, cache2); + } + private static GlobalSettings CreateGlobalSettings(Dictionary data) { var config = new ConfigurationBuilder() diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/ProviderUserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/ProviderUserRepositoryTests.cs index 0d1d28f33d..b502c6c997 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/ProviderUserRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/ProviderUserRepositoryTests.cs @@ -89,6 +89,286 @@ public class ProviderUserRepositoryTests Assert.Equal(serializedSsoConfigData, orgWithSsoDetails.SsoConfig); } + [Theory, DatabaseData] + public async Task GetManyByManyUsersAsync_WithMultipleUsers_ReturnsAllProviderUsers( + IUserRepository userRepository, + IProviderRepository providerRepository, + IProviderUserRepository providerUserRepository) + { + var user1 = await userRepository.CreateTestUserAsync(); + var user2 = await userRepository.CreateTestUserAsync(); + var user3 = await userRepository.CreateTestUserAsync(); + + var provider1 = await providerRepository.CreateAsync(new Provider + { + Name = "Test Provider 1", + Enabled = true, + Type = ProviderType.Msp + }); + + var provider2 = await providerRepository.CreateAsync(new Provider + { + Name = "Test Provider 2", + Enabled = true, + Type = ProviderType.Reseller + }); + + var providerUser1 = await providerUserRepository.CreateAsync(new ProviderUser + { + Id = Guid.NewGuid(), + ProviderId = provider1.Id, + UserId = user1.Id, + Status = ProviderUserStatusType.Confirmed, + Type = ProviderUserType.ProviderAdmin + }); + + var providerUser2 = await providerUserRepository.CreateAsync(new ProviderUser + { + Id = Guid.NewGuid(), + ProviderId = provider1.Id, + UserId = user2.Id, + Status = ProviderUserStatusType.Invited, + Type = ProviderUserType.ServiceUser + }); + + var providerUser3 = await providerUserRepository.CreateAsync(new ProviderUser + { + Id = Guid.NewGuid(), + ProviderId = provider2.Id, + UserId = user3.Id, + Status = ProviderUserStatusType.Confirmed, + Type = ProviderUserType.ProviderAdmin + }); + + var userIds = new[] { user1.Id, user2.Id, user3.Id }; + + var results = (await providerUserRepository.GetManyByManyUsersAsync(userIds)).ToList(); + + Assert.Equal(3, results.Count); + Assert.Contains(results, pu => pu.Id == providerUser1.Id && pu.UserId == user1.Id); + Assert.Contains(results, pu => pu.Id == providerUser2.Id && pu.UserId == user2.Id); + Assert.Contains(results, pu => pu.Id == providerUser3.Id && pu.UserId == user3.Id); + } + + [Theory, DatabaseData] + public async Task GetManyByManyUsersAsync_WithSingleUser_ReturnsSingleProviderUser( + IUserRepository userRepository, + IProviderRepository providerRepository, + IProviderUserRepository providerUserRepository) + { + var user = await userRepository.CreateTestUserAsync(); + + var provider = await providerRepository.CreateAsync(new Provider + { + Name = "Test Provider", + Enabled = true, + Type = ProviderType.Msp + }); + + var providerUser = await providerUserRepository.CreateAsync(new ProviderUser + { + Id = Guid.NewGuid(), + ProviderId = provider.Id, + UserId = user.Id, + Status = ProviderUserStatusType.Confirmed, + Type = ProviderUserType.ProviderAdmin + }); + + var results = (await providerUserRepository.GetManyByManyUsersAsync([user.Id])).ToList(); + + Assert.Single(results); + Assert.Equal(user.Id, results[0].UserId); + Assert.Equal(provider.Id, results[0].ProviderId); + } + + [Theory, DatabaseData] + public async Task GetManyByManyUsersAsync_WithUserHavingMultipleProviders_ReturnsAllProviderUsers( + IUserRepository userRepository, + IProviderRepository providerRepository, + IProviderUserRepository providerUserRepository) + { + var user = await userRepository.CreateTestUserAsync(); + + var provider1 = await providerRepository.CreateAsync(new Provider + { + Name = "Test Provider 1", + Enabled = true, + Type = ProviderType.Msp + }); + + var provider2 = await providerRepository.CreateAsync(new Provider + { + Name = "Test Provider 2", + Enabled = true, + Type = ProviderType.Reseller + }); + + var providerUser1 = await providerUserRepository.CreateAsync(new ProviderUser + { + ProviderId = provider1.Id, + UserId = user.Id, + Status = ProviderUserStatusType.Confirmed, + Type = ProviderUserType.ProviderAdmin + }); + + var providerUser2 = await providerUserRepository.CreateAsync(new ProviderUser + { + ProviderId = provider2.Id, + UserId = user.Id, + Status = ProviderUserStatusType.Confirmed, + Type = ProviderUserType.ServiceUser + }); + + var results = (await providerUserRepository.GetManyByManyUsersAsync([user.Id])).ToList(); + + Assert.Equal(2, results.Count); + Assert.Contains(results, pu => pu.Id == providerUser1.Id); + Assert.Contains(results, pu => pu.Id == providerUser2.Id); + } + + [Theory, DatabaseData] + public async Task GetManyByManyUsersAsync_WithEmptyUserIds_ReturnsEmpty( + IProviderUserRepository providerUserRepository) + { + var results = await providerUserRepository.GetManyByManyUsersAsync(Array.Empty()); + + Assert.Empty(results); + } + + [Theory, DatabaseData] + public async Task GetManyByManyUsersAsync_WithNonExistentUserIds_ReturnsEmpty( + IProviderUserRepository providerUserRepository) + { + var nonExistentUserIds = new[] { Guid.NewGuid(), Guid.NewGuid() }; + + var results = await providerUserRepository.GetManyByManyUsersAsync(nonExistentUserIds); + + Assert.Empty(results); + } + + [Theory, DatabaseData] + public async Task GetManyByManyUsersAsync_WithMixedExistentAndNonExistentUserIds_ReturnsOnlyExistent( + IUserRepository userRepository, + IProviderRepository providerRepository, + IProviderUserRepository providerUserRepository) + { + var existingUser = await userRepository.CreateTestUserAsync(); + + var provider = await providerRepository.CreateAsync(new Provider + { + Name = "Test Provider", + Enabled = true, + Type = ProviderType.Msp + }); + + var providerUser = await providerUserRepository.CreateAsync(new ProviderUser + { + ProviderId = provider.Id, + UserId = existingUser.Id, + Status = ProviderUserStatusType.Confirmed, + Type = ProviderUserType.ProviderAdmin + }); + + var userIds = new[] { existingUser.Id, Guid.NewGuid(), Guid.NewGuid() }; + + var results = (await providerUserRepository.GetManyByManyUsersAsync(userIds)).ToList(); + + Assert.Single(results); + Assert.Equal(existingUser.Id, results[0].UserId); + } + + [Theory, DatabaseData] + public async Task GetManyByManyUsersAsync_ReturnsAllStatuses( + IUserRepository userRepository, + IProviderRepository providerRepository, + IProviderUserRepository providerUserRepository) + { + var user1 = await userRepository.CreateTestUserAsync(); + var user2 = await userRepository.CreateTestUserAsync(); + var user3 = await userRepository.CreateTestUserAsync(); + + var provider = await providerRepository.CreateAsync(new Provider + { + Name = "Test Provider", + Enabled = true, + Type = ProviderType.Msp + }); + + await providerUserRepository.CreateAsync(new ProviderUser + { + ProviderId = provider.Id, + UserId = user1.Id, + Status = ProviderUserStatusType.Invited, + Type = ProviderUserType.ServiceUser + }); + + await providerUserRepository.CreateAsync(new ProviderUser + { + ProviderId = provider.Id, + UserId = user2.Id, + Status = ProviderUserStatusType.Accepted, + Type = ProviderUserType.ServiceUser + }); + + await providerUserRepository.CreateAsync(new ProviderUser + { + ProviderId = provider.Id, + UserId = user3.Id, + Status = ProviderUserStatusType.Confirmed, + Type = ProviderUserType.ProviderAdmin + }); + + var userIds = new[] { user1.Id, user2.Id, user3.Id }; + + var results = (await providerUserRepository.GetManyByManyUsersAsync(userIds)).ToList(); + + Assert.Equal(3, results.Count); + Assert.Contains(results, pu => pu.UserId == user1.Id && pu.Status == ProviderUserStatusType.Invited); + Assert.Contains(results, pu => pu.UserId == user2.Id && pu.Status == ProviderUserStatusType.Accepted); + Assert.Contains(results, pu => pu.UserId == user3.Id && pu.Status == ProviderUserStatusType.Confirmed); + } + + [Theory, DatabaseData] + public async Task GetManyByManyUsersAsync_ReturnsAllProviderUserTypes( + IUserRepository userRepository, + IProviderRepository providerRepository, + IProviderUserRepository providerUserRepository) + { + var user1 = await userRepository.CreateTestUserAsync(); + var user2 = await userRepository.CreateTestUserAsync(); + + var provider = await providerRepository.CreateAsync(new Provider + { + Name = "Test Provider", + Enabled = true, + Type = ProviderType.Msp + }); + + await providerUserRepository.CreateAsync(new ProviderUser + { + ProviderId = provider.Id, + UserId = user1.Id, + Status = ProviderUserStatusType.Confirmed, + Type = ProviderUserType.ServiceUser + }); + + await providerUserRepository.CreateAsync(new ProviderUser + { + ProviderId = provider.Id, + UserId = user2.Id, + Status = ProviderUserStatusType.Confirmed, + Type = ProviderUserType.ProviderAdmin + }); + + var userIds = new[] { user1.Id, user2.Id }; + + var results = (await providerUserRepository.GetManyByManyUsersAsync(userIds)).ToList(); + + Assert.Equal(2, results.Count); + Assert.Contains(results, pu => pu.UserId == user1.Id && pu.Type == ProviderUserType.ServiceUser); + Assert.Contains(results, pu => pu.UserId == user2.Id && pu.Type == ProviderUserType.ProviderAdmin); + } + private static void AssertProviderOrganizationDetails( ProviderUserOrganizationDetails actual, Organization expectedOrganization, @@ -139,4 +419,6 @@ public class ProviderUserRepositoryTests Assert.Equal(expectedProviderUser.Status, actual.Status); Assert.Equal(expectedProviderUser.Type, actual.Type); } + + } diff --git a/util/DbSeederUtility/Program.cs b/util/DbSeederUtility/Program.cs index 2d75b31934..0b41c1a692 100644 --- a/util/DbSeederUtility/Program.cs +++ b/util/DbSeederUtility/Program.cs @@ -34,6 +34,6 @@ public class Program var db = scopedServices.GetRequiredService(); var recipe = new OrganizationWithUsersRecipe(db); - recipe.Seed(name, users, domain); + recipe.Seed(name: name, domain: domain, users: users); } } diff --git a/util/Migrator/DbScripts/2025-12-03_00_ProviderUserGetManyByUserIds.sql b/util/Migrator/DbScripts/2025-12-03_00_ProviderUserGetManyByUserIds.sql new file mode 100644 index 0000000000..b112e02263 --- /dev/null +++ b/util/Migrator/DbScripts/2025-12-03_00_ProviderUserGetManyByUserIds.sql @@ -0,0 +1,13 @@ +CREATE OR ALTER PROCEDURE [dbo].[ProviderUser_ReadManyByManyUserIds] + @UserIds AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + SELECT + [pu].* + FROM + [dbo].[ProviderUserView] AS [pu] + INNER JOIN + @UserIds [u] ON [u].[Id] = [pu].[UserId] +END diff --git a/util/Seeder/Factories/OrganizationSeeder.cs b/util/Seeder/Factories/OrganizationSeeder.cs index f6f05d9525..012661501f 100644 --- a/util/Seeder/Factories/OrganizationSeeder.cs +++ b/util/Seeder/Factories/OrganizationSeeder.cs @@ -17,7 +17,31 @@ public class OrganizationSeeder Plan = "Enterprise (Annually)", PlanType = PlanType.EnterpriseAnnually, Seats = seats, - + UseCustomPermissions = true, + UseOrganizationDomains = true, + UseSecretsManager = true, + UseGroups = true, + UseDirectory = true, + UseEvents = true, + UseTotp = true, + Use2fa = true, + UseApi = true, + UseResetPassword = true, + UsePasswordManager = true, + UseAutomaticUserConfirmation = true, + SelfHost = true, + UsersGetPremium = true, + LimitCollectionCreation = true, + LimitCollectionDeletion = true, + LimitItemDeletion = true, + AllowAdminAccessToAllCollectionItems = true, + UseRiskInsights = true, + UseAdminSponsoredFamilies = true, + SyncSeats = true, + Status = OrganizationStatusType.Created, + //GatewayCustomerId = "example-customer-id", + //GatewaySubscriptionId = "example-subscription-id", + MaxStorageGb = 10, // Currently hardcoded to the values from https://github.com/bitwarden/sdk-internal/blob/main/crates/bitwarden-core/src/client/test_accounts.rs. // TODO: These should be dynamically generated by the SDK. PublicKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmIJbGMk6eZqVE7UxhZ46Weu2jKciqOiOkSVYtGvs61rfe9AXxtLaaZEKN4d4DmkZcF6dna2eXNxZmb7U4pwlttye8ksqISe6IUAZQox7auBpjopdCEPhKRg3BD/u8ks9UxSxgWe+fpebjt6gd5hsl1/5HOObn7SeU6EEU04cp3/eH7a4OTdXxB8oN62HGV9kM/ubM1goILgjoSJDbihMK0eb7b8hPHwcA/YOgKKiu/N3FighccdSMD5Pk+HfjacsFNZQa2EsqW09IvvSZ+iL6HQeZ1vwc/6TO1J7EOfJZFQcjoEL9LVI693efYoMZSmrPEWziZ4PvwpOOGo6OObyMQIDAQAB", @@ -28,17 +52,25 @@ public class OrganizationSeeder public static class OrgnaizationExtensions { - public static OrganizationUser CreateOrganizationUser(this Organization organization, User user) + /// + /// Creates an OrganizationUser with fields populated based on status. + /// For Invited status, only user.Email is used. For other statuses, user.Id is used. + /// + public static OrganizationUser CreateOrganizationUser( + this Organization organization, User user, OrganizationUserType type, OrganizationUserStatusType status) { + var isInvited = status == OrganizationUserStatusType.Invited; + var isConfirmed = status == OrganizationUserStatusType.Confirmed || status == OrganizationUserStatusType.Revoked; + return new OrganizationUser { Id = Guid.NewGuid(), OrganizationId = organization.Id, - UserId = user.Id, - - Key = "4.rY01mZFXHOsBAg5Fq4gyXuklWfm6mQASm42DJpx05a+e2mmp+P5W6r54WU2hlREX0uoTxyP91bKKwickSPdCQQ58J45LXHdr9t2uzOYyjVzpzebFcdMw1eElR9W2DW8wEk9+mvtWvKwu7yTebzND+46y1nRMoFydi5zPVLSlJEf81qZZ4Uh1UUMLwXz+NRWfixnGXgq2wRq1bH0n3mqDhayiG4LJKgGdDjWXC8W8MMXDYx24SIJrJu9KiNEMprJE+XVF9nQVNijNAjlWBqkDpsfaWTUfeVLRLctfAqW1blsmIv4RQ91PupYJZDNc8nO9ZTF3TEVM+2KHoxzDJrLs2Q==", - Type = OrganizationUserType.Admin, - Status = OrganizationUserStatusType.Confirmed + UserId = isInvited ? null : user.Id, + Email = isInvited ? user.Email : null, + Key = isConfirmed ? "4.rY01mZFXHOsBAg5Fq4gyXuklWfm6mQASm42DJpx05a+e2mmp+P5W6r54WU2hlREX0uoTxyP91bKKwickSPdCQQ58J45LXHdr9t2uzOYyjVzpzebFcdMw1eElR9W2DW8wEk9+mvtWvKwu7yTebzND+46y1nRMoFydi5zPVLSlJEf81qZZ4Uh1UUMLwXz+NRWfixnGXgq2wRq1bH0n3mqDhayiG4LJKgGdDjWXC8W8MMXDYx24SIJrJu9KiNEMprJE+XVF9nQVNijNAjlWBqkDpsfaWTUfeVLRLctfAqW1blsmIv4RQ91PupYJZDNc8nO9ZTF3TEVM+2KHoxzDJrLs2Q==" : null, + Type = type, + Status = status }; } diff --git a/util/Seeder/Recipes/CollectionsRecipe.cs b/util/Seeder/Recipes/CollectionsRecipe.cs new file mode 100644 index 0000000000..e0f9057418 --- /dev/null +++ b/util/Seeder/Recipes/CollectionsRecipe.cs @@ -0,0 +1,122 @@ +using Bit.Core.Enums; +using Bit.Core.Utilities; +using Bit.Infrastructure.EntityFramework.Repositories; +using LinqToDB.EntityFrameworkCore; + +namespace Bit.Seeder.Recipes; + +public class CollectionsRecipe(DatabaseContext db) +{ + /// + /// Adds collections to an organization and creates relationships between users and collections. + /// + /// The ID of the organization to add collections to. + /// The number of collections to add. + /// The IDs of the users to create relationships with. + /// The maximum number of users to create relationships with. + public List AddToOrganization(Guid organizationId, int collections, List organizationUserIds, int maxUsersWithRelationships = 1000) + { + var collectionList = CreateAndSaveCollections(organizationId, collections); + + if (collectionList.Any()) + { + CreateAndSaveCollectionUserRelationships(collectionList, organizationUserIds, maxUsersWithRelationships); + } + + return collectionList.Select(c => c.Id).ToList(); + } + + private List CreateAndSaveCollections(Guid organizationId, int count) + { + var collectionList = new List(); + + for (var i = 0; i < count; i++) + { + collectionList.Add(new Core.Entities.Collection + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organizationId, + Name = $"Collection {i + 1}", + Type = CollectionType.SharedCollection, + CreationDate = DateTime.UtcNow, + RevisionDate = DateTime.UtcNow + }); + } + + if (collectionList.Any()) + { + db.BulkCopy(collectionList); + } + + return collectionList; + } + + private void CreateAndSaveCollectionUserRelationships( + List collections, + List organizationUserIds, + int maxUsersWithRelationships) + { + if (!organizationUserIds.Any() || maxUsersWithRelationships <= 0) + { + return; + } + + var collectionUsers = BuildCollectionUserRelationships(collections, organizationUserIds, maxUsersWithRelationships); + + if (collectionUsers.Any()) + { + db.BulkCopy(collectionUsers); + } + } + + /// + /// Creates user-to-collection relationships with varied assignment patterns for realistic test data. + /// Each user gets 1-3 collections based on a rotating pattern. + /// + private List BuildCollectionUserRelationships( + List collections, + List organizationUserIds, + int maxUsersWithRelationships) + { + var maxRelationships = Math.Min(organizationUserIds.Count, maxUsersWithRelationships); + var collectionUsers = new List(); + + for (var i = 0; i < maxRelationships; i++) + { + var orgUserId = organizationUserIds[i]; + var userCollectionAssignments = CreateCollectionAssignmentsForUser(collections, orgUserId, i); + collectionUsers.AddRange(userCollectionAssignments); + } + + return collectionUsers; + } + + /// + /// Assigns collections to a user with varying permissions. + /// Pattern: 1-3 collections per user (cycles: 1, 2, 3, 1, 2, 3...). + /// First collection has Manage rights, subsequent ones are ReadOnly. + /// + private List CreateCollectionAssignmentsForUser( + List collections, + Guid organizationUserId, + int userIndex) + { + var assignments = new List(); + var userCollectionCount = (userIndex % 3) + 1; // Cycles through 1, 2, or 3 collections + + for (var j = 0; j < userCollectionCount; j++) + { + var collectionIndex = (userIndex + j) % collections.Count; // Distribute across available collections + assignments.Add(new Core.Entities.CollectionUser + { + CollectionId = collections[collectionIndex].Id, + OrganizationUserId = organizationUserId, + ReadOnly = j > 0, // First assignment gets write access + HidePasswords = false, + Manage = j == 0 // First assignment gets manage permissions + }); + } + + return assignments; + } +} diff --git a/util/Seeder/Recipes/GroupsRecipe.cs b/util/Seeder/Recipes/GroupsRecipe.cs new file mode 100644 index 0000000000..3c8156d921 --- /dev/null +++ b/util/Seeder/Recipes/GroupsRecipe.cs @@ -0,0 +1,94 @@ +using Bit.Core.Utilities; +using Bit.Infrastructure.EntityFramework.Repositories; +using LinqToDB.EntityFrameworkCore; + +namespace Bit.Seeder.Recipes; + +public class GroupsRecipe(DatabaseContext db) +{ + /// + /// Adds groups to an organization and creates relationships between users and groups. + /// + /// The ID of the organization to add groups to. + /// The number of groups to add. + /// The IDs of the users to create relationships with. + /// The maximum number of users to create relationships with. + public List AddToOrganization(Guid organizationId, int groups, List organizationUserIds, int maxUsersWithRelationships = 1000) + { + var groupList = CreateAndSaveGroups(organizationId, groups); + + if (groupList.Any()) + { + CreateAndSaveGroupUserRelationships(groupList, organizationUserIds, maxUsersWithRelationships); + } + + return groupList.Select(g => g.Id).ToList(); + } + + private List CreateAndSaveGroups(Guid organizationId, int count) + { + var groupList = new List(); + + for (var i = 0; i < count; i++) + { + groupList.Add(new Core.AdminConsole.Entities.Group + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organizationId, + Name = $"Group {i + 1}" + }); + } + + if (groupList.Any()) + { + db.BulkCopy(groupList); + } + + return groupList; + } + + private void CreateAndSaveGroupUserRelationships( + List groups, + List organizationUserIds, + int maxUsersWithRelationships) + { + if (!organizationUserIds.Any() || maxUsersWithRelationships <= 0) + { + return; + } + + var groupUsers = BuildGroupUserRelationships(groups, organizationUserIds, maxUsersWithRelationships); + + if (groupUsers.Any()) + { + db.BulkCopy(groupUsers); + } + } + + /// + /// Creates user-to-group relationships with distributed assignment patterns for realistic test data. + /// Each user is assigned to one group, distributed evenly across available groups. + /// + private List BuildGroupUserRelationships( + List groups, + List organizationUserIds, + int maxUsersWithRelationships) + { + var maxRelationships = Math.Min(organizationUserIds.Count, maxUsersWithRelationships); + var groupUsers = new List(); + + for (var i = 0; i < maxRelationships; i++) + { + var orgUserId = organizationUserIds[i]; + var groupIndex = i % groups.Count; // Round-robin distribution across groups + + groupUsers.Add(new Core.AdminConsole.Entities.GroupUser + { + GroupId = groups[groupIndex].Id, + OrganizationUserId = orgUserId + }); + } + + return groupUsers; + } +} diff --git a/util/Seeder/Recipes/OrganizationDomainRecipe.cs b/util/Seeder/Recipes/OrganizationDomainRecipe.cs new file mode 100644 index 0000000000..b62dd5115e --- /dev/null +++ b/util/Seeder/Recipes/OrganizationDomainRecipe.cs @@ -0,0 +1,25 @@ +using Bit.Infrastructure.EntityFramework.Models; +using Bit.Infrastructure.EntityFramework.Repositories; + +namespace Bit.Seeder.Recipes; + +public class OrganizationDomainRecipe(DatabaseContext db) +{ + public void AddVerifiedDomainToOrganization(Guid organizationId, string domainName) + { + var domain = new OrganizationDomain + { + Id = Guid.NewGuid(), + OrganizationId = organizationId, + DomainName = domainName, + Txt = Guid.NewGuid().ToString("N"), + CreationDate = DateTime.UtcNow, + }; + + domain.SetVerifiedDate(); + domain.SetLastCheckedDate(); + + db.Add(domain); + db.SaveChanges(); + } +} diff --git a/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs b/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs index fb06c091ae..7678c3a9ce 100644 --- a/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs +++ b/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs @@ -1,4 +1,5 @@ -using Bit.Infrastructure.EntityFramework.Models; +using Bit.Core.Enums; +using Bit.Infrastructure.EntityFramework.Models; using Bit.Infrastructure.EntityFramework.Repositories; using Bit.Seeder.Factories; using LinqToDB.EntityFrameworkCore; @@ -7,11 +8,12 @@ namespace Bit.Seeder.Recipes; public class OrganizationWithUsersRecipe(DatabaseContext db) { - public Guid Seed(string name, int users, string domain) + public Guid Seed(string name, string domain, int users, OrganizationUserStatusType usersStatus = OrganizationUserStatusType.Confirmed) { - var organization = OrganizationSeeder.CreateEnterprise(name, domain, users); - var user = UserSeeder.CreateUser($"admin@{domain}"); - var orgUser = organization.CreateOrganizationUser(user); + var seats = Math.Max(users + 1, 1000); + var organization = OrganizationSeeder.CreateEnterprise(name, domain, seats); + var ownerUser = UserSeeder.CreateUser($"owner@{domain}"); + var ownerOrgUser = organization.CreateOrganizationUser(ownerUser, OrganizationUserType.Owner, OrganizationUserStatusType.Confirmed); var additionalUsers = new List(); var additionalOrgUsers = new List(); @@ -19,12 +21,12 @@ public class OrganizationWithUsersRecipe(DatabaseContext db) { var additionalUser = UserSeeder.CreateUser($"user{i}@{domain}"); additionalUsers.Add(additionalUser); - additionalOrgUsers.Add(organization.CreateOrganizationUser(additionalUser)); + additionalOrgUsers.Add(organization.CreateOrganizationUser(additionalUser, OrganizationUserType.User, usersStatus)); } db.Add(organization); - db.Add(user); - db.Add(orgUser); + db.Add(ownerUser); + db.Add(ownerOrgUser); db.SaveChanges();