1
0
mirror of https://github.com/bitwarden/server synced 2025-12-10 05:13:48 +00:00

Merge branch 'main' into billing/PM-28128/stripe-bank-transfer-transaction-records

This commit is contained in:
Alex Morask
2025-12-05 08:52:37 -06:00
23 changed files with 1740 additions and 370 deletions

View File

@@ -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;
/// <li>All organization users are compliant with the Single organization policy</li>
/// <li>No provider users exist</li>
/// </ul>
///
/// This class also performs side effects when the policy is being enabled or disabled. They are:
/// <ul>
/// <li>Sets the UseAutomaticUserConfirmation organization feature to match the policy update</li>
/// </ul>
/// </summary>
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<string> 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<string> 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<string> ValidateSingleOrgPolicyComplianceAsync(Guid organizationId)
private async Task<string> ValidateUserComplianceWithSingleOrgAsync(Guid organizationId,
ICollection<OrganizationUserUserDetails> organizationUsers)
{
var singleOrgPolicy = await policyRepository.GetByOrganizationIdTypeAsync(organizationId, PolicyType.SingleOrg);
if (singleOrgPolicy is not { Enabled: true })
{
return _singleOrgPolicyNotEnabledErrorMessage;
}
return await ValidateUserComplianceWithSingleOrgAsync(organizationId);
}
private async Task<string> 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<string> ValidateNoProviderUsersAsync(Guid organizationId)
private async Task<string> ValidateNoProviderUsersAsync(ICollection<OrganizationUserUserDetails> 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;
}
}

View File

@@ -12,6 +12,7 @@ public interface IProviderUserRepository : IRepository<ProviderUser, Guid>
Task<int> GetCountByProviderAsync(Guid providerId, string email, bool onlyRegisteredUsers);
Task<ICollection<ProviderUser>> GetManyAsync(IEnumerable<Guid> ids);
Task<ICollection<ProviderUser>> GetManyByUserAsync(Guid userId);
Task<ICollection<ProviderUser>> GetManyByManyUsersAsync(IEnumerable<Guid> userIds);
Task<ProviderUser?> GetByProviderUserAsync(Guid providerId, Guid userId);
Task<ICollection<ProviderUser>> GetManyByProviderAsync(Guid providerId, ProviderUserType? type = null);
Task<ICollection<ProviderUserUserDetails>> GetManyDetailsByProviderAsync(Guid providerId, ProviderUserStatusType? status = null);

View File

@@ -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;

View File

@@ -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"

View File

@@ -18,9 +18,12 @@ public static class ExtendedCacheServiceCollectionExtensions
/// Adds a new, named Fusion Cache <see href="https://github.com/ZiggyCreatures/FusionCache"/> to the service
/// collection. If an existing cache of the same name is found, it will do nothing.<br/>
/// <br/>
/// <b>Note</b>: When re-using the existing Redis cache, it is expected to call this method <b>after</b> calling
/// <code>services.AddDistributedCache(globalSettings)</code><br />This ensures that DI correctly finds,
/// configures, and re-uses all the shared Redis architecture.
/// <b>Note</b>: When re-using an existing distributed cache, it is expected to call this method <b>after</b> calling
/// <code>services.AddDistributedCache(globalSettings)</code><br />This ensures that DI correctly finds
/// and re-uses the shared distributed cache infrastructure.<br />
/// <br />
/// <b>Backplane</b>: Cross-instance cache invalidation is only available when using Redis.
/// Non-Redis distributed caches operate with eventual consistency across multiple instances.
/// </summary>
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<IConnectionMultiplexer>(sp =>
CreateConnectionMultiplexer(sp, cacheName, globalSettings.DistributedCache.Redis.ConnectionString));
@@ -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<IConnectionMultiplexer>(
cacheName,

View File

@@ -61,6 +61,18 @@ public class ProviderUserRepository : Repository<ProviderUser, Guid>, IProviderU
}
}
public async Task<ICollection<ProviderUser>> GetManyByManyUsersAsync(IEnumerable<Guid> userIds)
{
await using var connection = new SqlConnection(ConnectionString);
var results = await connection.QueryAsync<ProviderUser>(
"[dbo].[ProviderUser_ReadManyByManyUserIds]",
new { UserIds = userIds.ToGuidIdArrayTVP() },
commandType: CommandType.StoredProcedure);
return results.ToList();
}
public async Task<ProviderUser?> GetByProviderUserAsync(Guid providerId, Guid userId)
{
using (var connection = new SqlConnection(ConnectionString))

View File

@@ -96,6 +96,20 @@ public class ProviderUserRepository :
return await query.ToArrayAsync();
}
}
public async Task<ICollection<ProviderUser>> GetManyByManyUsersAsync(IEnumerable<Guid> 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<ProviderUser> GetByProviderUserAsync(Guid providerId, Guid userId)
{
using (var scope = ServiceScopeFactory.CreateScope())

View File

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

View File

@@ -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)
{
/// <summary>
/// Tests PUT /organizations/{orgId}/groups/{id}
/// </summary>
[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);
}
}

View File

@@ -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)
{
/// <summary>
/// Tests GET /organizations/{orgId}/users?includeCollections=true
/// </summary>
[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");
}
/// <summary>
/// Tests GET /organizations/{orgId}/users/mini-details
/// </summary>
[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);
}
/// <summary>
/// Tests GET /organizations/{orgId}/users/{id}?includeGroups=true
/// </summary>
[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);
}
/// <summary>
/// Tests GET /organizations/{orgId}/users/{id}/reset-password-details
/// </summary>
[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);
}
/// <summary>
/// Tests POST /organizations/{orgId}/users/confirm
/// </summary>
[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);
}
/// <summary>
/// Tests POST /organizations/{orgId}/users/remove
/// </summary>
[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);
}
/// <summary>
/// Tests PUT /organizations/{orgId}/users/revoke
/// </summary>
[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);
}
/// <summary>
/// Tests PUT /organizations/{orgId}/users/restore
/// </summary>
[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);
}
/// <summary>
/// Tests POST /organizations/{orgId}/users/delete-account
/// </summary>
[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);
}
/// <summary>
/// Tests PUT /organizations/{orgId}/users/{id}
/// </summary>
[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);
}
/// <summary>
/// Tests PUT /organizations/{orgId}/users/enable-secrets-manager
/// </summary>
[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);
}
/// <summary>
/// Tests DELETE /organizations/{orgId}/users/{id}/delete-account
/// </summary>
[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);
}
/// <summary>
/// Tests POST /organizations/{orgId}/users/invite
/// </summary>
[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<SelectionReadOnlyRequestModel>(),
Groups = Array.Empty<Guid>(),
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);
}
/// <summary>
/// Tests POST /organizations/{orgId}/users/reinvite
/// </summary>
[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);
}
}

View File

@@ -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)
{
/// <summary>
/// Tests DELETE /organizations/{id} with password verification
/// </summary>
[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);
}
/// <summary>
/// Tests POST /organizations/{id}/delete-recover-token with token verification
/// </summary>
[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<IDataProtectorTokenFactory<OrgDeleteTokenable>>();
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);
}
/// <summary>
/// Tests POST /organizations/create-without-payment
/// </summary>
[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);
}
}

View File

@@ -192,6 +192,15 @@ public static class OrganizationTestHelpers
await policyRepository.CreateAsync(policy);
}
/// <summary>
/// Generates a unique random domain name for testing purposes.
/// </summary>
/// <returns>A domain string like "a1b2c3d4.com"</returns>
public static string GenerateRandomDomain()
{
return $"{Guid.NewGuid().ToString("N").Substring(0, 8)}.com";
}
/// <summary>
/// Creates a user account without a Master Password and adds them as a member to the specified organization.
/// </summary>

View File

@@ -0,0 +1,32 @@
using System.Net.Http.Headers;
using Bit.Api.IntegrationTest.Factories;
namespace Bit.Api.IntegrationTest.Helpers;
/// <summary>
/// Helper methods for performance tests to reduce code duplication.
/// </summary>
public static class PerformanceTestHelpers
{
/// <summary>
/// Standard password hash used across performance tests.
/// </summary>
public const string StandardPasswordHash = "c55hlJ/cfdvTd4awTXUqow6X3cOQCfGwn11o3HblnPs=";
/// <summary>
/// Authenticates an HttpClient with a bearer token for the specified user.
/// </summary>
/// <param name="factory">The application factory to use for login.</param>
/// <param name="client">The HttpClient to authenticate.</param>
/// <param name="email">The user's email address.</param>
/// <param name="masterPasswordHash">The user's master password hash. Defaults to StandardPasswordHash.</param>
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);
}
}

View File

@@ -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<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
sutProvider.GetDependency<IPolicyRepository>()
.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<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
sutProvider.GetDependency<IPolicyRepository>()
.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<AutomaticUserConfirmationPolicyEventHandler> 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<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.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<AutomaticUserConfirmationPolicyEventHandler> 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<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([orgUser]);
@@ -146,7 +105,7 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
.Returns([otherOrgUser]);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
.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<AutomaticUserConfirmationPolicyEventHandler> 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<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([orgUser]);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
.Returns([]);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
.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<AutomaticUserConfirmationPolicyEventHandler> 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<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([orgUser]);
@@ -225,7 +183,7 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
.Returns([]);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
.Returns([]);
// Act
@@ -249,9 +207,10 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
// Assert
Assert.True(string.IsNullOrEmpty(result));
await sutProvider.GetDependency<IPolicyRepository>()
await sutProvider.GetDependency<IOrganizationUserRepository>()
.DidNotReceive()
.GetByOrganizationIdTypeAsync(Arg.Any<Guid>(), Arg.Any<PolicyType>());
.GetManyDetailsByOrganizationAsync(Arg.Any<Guid>());
}
[Theory, BitAutoData]
@@ -268,21 +227,18 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
// Assert
Assert.True(string.IsNullOrEmpty(result));
await sutProvider.GetDependency<IPolicyRepository>()
await sutProvider.GetDependency<IOrganizationUserRepository>()
.DidNotReceive()
.GetByOrganizationIdTypeAsync(Arg.Any<Guid>(), Arg.Any<PolicyType>());
.GetManyDetailsByOrganizationAsync(Arg.Any<Guid>());
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_IncludesOwnersAndAdmins_InComplianceCheck(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
Guid nonCompliantOwnerId,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> 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<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.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<AutomaticUserConfirmationPolicyEventHandler> 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<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([invitedUser]);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
.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<AutomaticUserConfirmationPolicyEventHandler> 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<IPolicyRepository>()
.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<IOrganizationUserRepository>()
var orgUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
orgUserRepository
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([revokedUser]);
orgUserRepository.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
.Returns([additionalOrgUser]);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
.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<AutomaticUserConfirmationPolicyEventHandler> 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<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.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<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([]);
sutProvider.GetDependency<IProviderUserRepository>()
.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<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var savePolicyModel = new SavePolicyModel(policyUpdate);
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([]);
sutProvider.GetDependency<IProviderUserRepository>()
.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<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
organization.Id = policyUpdate.OrganizationId;
organization.UseAutomaticUserConfirmation = false;
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(policyUpdate.OrganizationId)
.Returns(organization);
// Act
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null);
// Assert
await sutProvider.GetDependency<IOrganizationRepository>()
.Received(1)
.UpsertAsync(Arg.Is<Organization>(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<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
organization.Id = policyUpdate.OrganizationId;
organization.UseAutomaticUserConfirmation = true;
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(policyUpdate.OrganizationId)
.Returns(organization);
// Act
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null);
// Assert
await sutProvider.GetDependency<IOrganizationRepository>()
.Received(1)
.UpsertAsync(Arg.Is<Organization>(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<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(policyUpdate.OrganizationId)
.Returns((Organization?)null);
// Act
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null);
// Assert
await sutProvider.GetDependency<IOrganizationRepository>()
.DidNotReceive()
.UpsertAsync(Arg.Any<Organization>());
}
[Theory, BitAutoData]
public async Task ExecutePreUpsertSideEffectAsync_CallsOnSaveSideEffectsAsync(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.AutomaticUserConfirmation)] Policy currentPolicy,
Organization organization,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
organization.Id = policyUpdate.OrganizationId;
currentPolicy.OrganizationId = policyUpdate.OrganizationId;
var savePolicyModel = new SavePolicyModel(policyUpdate);
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(policyUpdate.OrganizationId)
.Returns(organization);
// Act
await sutProvider.Sut.ExecutePreUpsertSideEffectAsync(savePolicyModel, currentPolicy);
// Assert
await sutProvider.GetDependency<IOrganizationRepository>()
.Received(1)
.UpsertAsync(Arg.Is<Organization>(o =>
o.Id == organization.Id &&
o.UseAutomaticUserConfirmation == policyUpdate.Enabled));
}
[Theory, BitAutoData]
public async Task OnSaveSideEffectsAsync_UpdatesRevisionDate(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
Organization organization,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
organization.Id = policyUpdate.OrganizationId;
var originalRevisionDate = DateTime.UtcNow.AddDays(-1);
organization.RevisionDate = originalRevisionDate;
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(policyUpdate.OrganizationId)
.Returns(organization);
// Act
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null);
// Assert
await sutProvider.GetDependency<IOrganizationRepository>()
.Received(1)
.UpsertAsync(Arg.Is<Organization>(o =>
o.Id == organization.Id &&
o.RevisionDate > originalRevisionDate));
}
}

View File

@@ -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<IDistributedCache>());
_services.AddExtendedCache(_cacheName, _globalSettings, settings);
using var provider = _services.BuildServiceProvider();
var cache = provider.GetRequiredKeyedService<IFusionCache>(_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<IConnectionMultiplexer>();
_services.AddSingleton(mockMultiplexer);
_services.AddExtendedCache(_cacheName, globalSettings, settings);
using var provider = _services.BuildServiceProvider();
var cache = provider.GetRequiredKeyedService<IFusionCache>(_cacheName);
Assert.True(cache.HasDistributedCache);
Assert.True(cache.HasBackplane);
// Verify same multiplexer was reused (TryAdd didn't replace it)
var resolvedMux = provider.GetRequiredService<IConnectionMultiplexer>();
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<IDistributedCache>());
_services.AddExtendedCache(_cacheName, _globalSettings, settings);
using var provider = _services.BuildServiceProvider();
var cache = provider.GetRequiredKeyedService<IFusionCache>(_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<IConnectionMultiplexer>());
_services.AddExtendedCache(_cacheName, _globalSettings, settings);
using var provider = _services.BuildServiceProvider();
var cache = provider.GetRequiredKeyedService<IFusionCache>(_cacheName);
Assert.True(cache.HasDistributedCache);
Assert.True(cache.HasBackplane);
// Verify keyed services exist
var keyedMux = provider.GetRequiredKeyedService<IConnectionMultiplexer>(_cacheName);
Assert.NotNull(keyedMux);
var keyedRedis = provider.GetRequiredKeyedService<IDistributedCache>(_cacheName);
Assert.NotNull(keyedRedis);
var keyedBackplane = provider.GetRequiredKeyedService<IFusionCacheBackplane>(_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<IFusionCache>(_cacheName);
Assert.False(cache.HasDistributedCache);
Assert.False(cache.HasBackplane);
// Verify L1 memory cache still works
cache.Set("key", "value");
var result = cache.GetOrDefault<string>("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<IConnectionMultiplexer>());
_services.AddExtendedCache("Cache1", _globalSettings, redisSettings);
// Setup Cache2 (non-Redis)
_services.AddKeyedSingleton("Cache2", Substitute.For<IDistributedCache>());
_services.AddExtendedCache("Cache2", _globalSettings, nonRedisSettings);
using var provider = _services.BuildServiceProvider();
var cache1 = provider.GetRequiredKeyedService<IFusionCache>("Cache1");
var cache2 = provider.GetRequiredKeyedService<IFusionCache>("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<string, string?> data)
{
var config = new ConfigurationBuilder()

View File

@@ -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<Guid>());
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);
}
}

View File

@@ -34,6 +34,6 @@ public class Program
var db = scopedServices.GetRequiredService<DatabaseContext>();
var recipe = new OrganizationWithUsersRecipe(db);
recipe.Seed(name, users, domain);
recipe.Seed(name: name, domain: domain, users: users);
}
}

View File

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

View File

@@ -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)
/// <summary>
/// Creates an OrganizationUser with fields populated based on status.
/// For Invited status, only user.Email is used. For other statuses, user.Id is used.
/// </summary>
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
};
}

View File

@@ -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)
{
/// <summary>
/// Adds collections to an organization and creates relationships between users and collections.
/// </summary>
/// <param name="organizationId">The ID of the organization to add collections to.</param>
/// <param name="collections">The number of collections to add.</param>
/// <param name="organizationUserIds">The IDs of the users to create relationships with.</param>
/// <param name="maxUsersWithRelationships">The maximum number of users to create relationships with.</param>
public List<Guid> AddToOrganization(Guid organizationId, int collections, List<Guid> 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<Core.Entities.Collection> CreateAndSaveCollections(Guid organizationId, int count)
{
var collectionList = new List<Core.Entities.Collection>();
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<Core.Entities.Collection> collections,
List<Guid> organizationUserIds,
int maxUsersWithRelationships)
{
if (!organizationUserIds.Any() || maxUsersWithRelationships <= 0)
{
return;
}
var collectionUsers = BuildCollectionUserRelationships(collections, organizationUserIds, maxUsersWithRelationships);
if (collectionUsers.Any())
{
db.BulkCopy(collectionUsers);
}
}
/// <summary>
/// Creates user-to-collection relationships with varied assignment patterns for realistic test data.
/// Each user gets 1-3 collections based on a rotating pattern.
/// </summary>
private List<Core.Entities.CollectionUser> BuildCollectionUserRelationships(
List<Core.Entities.Collection> collections,
List<Guid> organizationUserIds,
int maxUsersWithRelationships)
{
var maxRelationships = Math.Min(organizationUserIds.Count, maxUsersWithRelationships);
var collectionUsers = new List<Core.Entities.CollectionUser>();
for (var i = 0; i < maxRelationships; i++)
{
var orgUserId = organizationUserIds[i];
var userCollectionAssignments = CreateCollectionAssignmentsForUser(collections, orgUserId, i);
collectionUsers.AddRange(userCollectionAssignments);
}
return collectionUsers;
}
/// <summary>
/// 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.
/// </summary>
private List<Core.Entities.CollectionUser> CreateCollectionAssignmentsForUser(
List<Core.Entities.Collection> collections,
Guid organizationUserId,
int userIndex)
{
var assignments = new List<Core.Entities.CollectionUser>();
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;
}
}

View File

@@ -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)
{
/// <summary>
/// Adds groups to an organization and creates relationships between users and groups.
/// </summary>
/// <param name="organizationId">The ID of the organization to add groups to.</param>
/// <param name="groups">The number of groups to add.</param>
/// <param name="organizationUserIds">The IDs of the users to create relationships with.</param>
/// <param name="maxUsersWithRelationships">The maximum number of users to create relationships with.</param>
public List<Guid> AddToOrganization(Guid organizationId, int groups, List<Guid> 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<Core.AdminConsole.Entities.Group> CreateAndSaveGroups(Guid organizationId, int count)
{
var groupList = new List<Core.AdminConsole.Entities.Group>();
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<Core.AdminConsole.Entities.Group> groups,
List<Guid> organizationUserIds,
int maxUsersWithRelationships)
{
if (!organizationUserIds.Any() || maxUsersWithRelationships <= 0)
{
return;
}
var groupUsers = BuildGroupUserRelationships(groups, organizationUserIds, maxUsersWithRelationships);
if (groupUsers.Any())
{
db.BulkCopy(groupUsers);
}
}
/// <summary>
/// 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.
/// </summary>
private List<Core.AdminConsole.Entities.GroupUser> BuildGroupUserRelationships(
List<Core.AdminConsole.Entities.Group> groups,
List<Guid> organizationUserIds,
int maxUsersWithRelationships)
{
var maxRelationships = Math.Min(organizationUserIds.Count, maxUsersWithRelationships);
var groupUsers = new List<Core.AdminConsole.Entities.GroupUser>();
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;
}
}

View File

@@ -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();
}
}

View File

@@ -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<User>();
var additionalOrgUsers = new List<OrganizationUser>();
@@ -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();