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:
-///
-/// - Sets the UseAutomaticUserConfirmation organization feature to match the policy update
-///
///
public class AutomaticUserConfirmationPolicyEventHandler(
IOrganizationUserRepository organizationUserRepository,
- IProviderUserRepository providerUserRepository,
- IPolicyRepository policyRepository,
- IOrganizationRepository organizationRepository,
- TimeProvider timeProvider)
- : IPolicyValidator, IPolicyValidationEvent, IOnPolicyPreUpdateEvent, IEnforceDependentPoliciesEvent
+ 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();