mirror of
https://github.com/bitwarden/server
synced 2025-12-10 21:33:41 +00:00
Merge branch 'main' into billing/PM-28128/stripe-bank-transfer-transaction-records
This commit is contained in:
@@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
|||||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
|
|
||||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
|
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>All organization users are compliant with the Single organization policy</li>
|
||||||
/// <li>No provider users exist</li>
|
/// <li>No provider users exist</li>
|
||||||
/// </ul>
|
/// </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>
|
/// </summary>
|
||||||
public class AutomaticUserConfirmationPolicyEventHandler(
|
public class AutomaticUserConfirmationPolicyEventHandler(
|
||||||
IOrganizationUserRepository organizationUserRepository,
|
IOrganizationUserRepository organizationUserRepository,
|
||||||
IProviderUserRepository providerUserRepository,
|
IProviderUserRepository providerUserRepository)
|
||||||
IPolicyRepository policyRepository,
|
: IPolicyValidator, IPolicyValidationEvent, IEnforceDependentPoliciesEvent
|
||||||
IOrganizationRepository organizationRepository,
|
|
||||||
TimeProvider timeProvider)
|
|
||||||
: IPolicyValidator, IPolicyValidationEvent, IOnPolicyPreUpdateEvent, IEnforceDependentPoliciesEvent
|
|
||||||
{
|
{
|
||||||
public PolicyType Type => PolicyType.AutomaticUserConfirmation;
|
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 =
|
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.";
|
"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) =>
|
public async Task<string> ValidateAsync(SavePolicyModel savePolicyModel, Policy? currentPolicy) =>
|
||||||
await ValidateAsync(savePolicyModel.PolicyUpdate, currentPolicy);
|
await ValidateAsync(savePolicyModel.PolicyUpdate, currentPolicy);
|
||||||
|
|
||||||
public async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy)
|
public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) =>
|
||||||
{
|
Task.CompletedTask;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<string> ValidateEnablingPolicyAsync(Guid organizationId)
|
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))
|
if (!string.IsNullOrWhiteSpace(singleOrgValidationError))
|
||||||
{
|
{
|
||||||
return singleOrgValidationError;
|
return singleOrgValidationError;
|
||||||
}
|
}
|
||||||
|
|
||||||
var providerValidationError = await ValidateNoProviderUsersAsync(organizationId);
|
var providerValidationError = await ValidateNoProviderUsersAsync(organizationUsers);
|
||||||
if (!string.IsNullOrWhiteSpace(providerValidationError))
|
if (!string.IsNullOrWhiteSpace(providerValidationError))
|
||||||
{
|
{
|
||||||
return providerValidationError;
|
return providerValidationError;
|
||||||
@@ -90,42 +71,24 @@ public class AutomaticUserConfirmationPolicyEventHandler(
|
|||||||
return string.Empty;
|
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(
|
var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync(
|
||||||
organizationUsers.Select(ou => ou.UserId!.Value)))
|
organizationUsers.Select(ou => ou.UserId!.Value)))
|
||||||
.Any(uo => uo.OrganizationId != organizationId &&
|
.Any(uo => uo.OrganizationId != organizationId
|
||||||
uo.Status != OrganizationUserStatusType.Invited);
|
&& uo.Status != OrganizationUserStatusType.Invited);
|
||||||
|
|
||||||
return hasNonCompliantUser ? _usersNotCompliantWithSingleOrgErrorMessage : string.Empty;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public interface IProviderUserRepository : IRepository<ProviderUser, Guid>
|
|||||||
Task<int> GetCountByProviderAsync(Guid providerId, string email, bool onlyRegisteredUsers);
|
Task<int> GetCountByProviderAsync(Guid providerId, string email, bool onlyRegisteredUsers);
|
||||||
Task<ICollection<ProviderUser>> GetManyAsync(IEnumerable<Guid> ids);
|
Task<ICollection<ProviderUser>> GetManyAsync(IEnumerable<Guid> ids);
|
||||||
Task<ICollection<ProviderUser>> GetManyByUserAsync(Guid userId);
|
Task<ICollection<ProviderUser>> GetManyByUserAsync(Guid userId);
|
||||||
|
Task<ICollection<ProviderUser>> GetManyByManyUsersAsync(IEnumerable<Guid> userIds);
|
||||||
Task<ProviderUser?> GetByProviderUserAsync(Guid providerId, Guid userId);
|
Task<ProviderUser?> GetByProviderUserAsync(Guid providerId, Guid userId);
|
||||||
Task<ICollection<ProviderUser>> GetManyByProviderAsync(Guid providerId, ProviderUserType? type = null);
|
Task<ICollection<ProviderUser>> GetManyByProviderAsync(Guid providerId, ProviderUserType? type = null);
|
||||||
Task<ICollection<ProviderUserUserDetails>> GetManyDetailsByProviderAsync(Guid providerId, ProviderUserStatusType? status = null);
|
Task<ICollection<ProviderUserUserDetails>> GetManyDetailsByProviderAsync(Guid providerId, ProviderUserStatusType? status = null);
|
||||||
|
|||||||
@@ -732,7 +732,7 @@ public class GlobalSettings : IGlobalSettings
|
|||||||
public class ExtendedCacheSettings
|
public class ExtendedCacheSettings
|
||||||
{
|
{
|
||||||
public bool EnableDistributedCache { get; set; } = true;
|
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 IConnectionStringSettings Redis { get; set; } = new ConnectionStringSettings();
|
||||||
public TimeSpan Duration { get; set; } = TimeSpan.FromMinutes(30);
|
public TimeSpan Duration { get; set; } = TimeSpan.FromMinutes(30);
|
||||||
public bool IsFailSafeEnabled { get; set; } = true;
|
public bool IsFailSafeEnabled { get; set; } = true;
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ services.AddExtendedCache("MyFeatureCache", globalSettings, new GlobalSettings.E
|
|||||||
// Option 4: Isolated Redis for specialized features
|
// Option 4: Isolated Redis for specialized features
|
||||||
services.AddExtendedCache("SpecializedCache", globalSettings, new GlobalSettings.ExtendedCacheSettings
|
services.AddExtendedCache("SpecializedCache", globalSettings, new GlobalSettings.ExtendedCacheSettings
|
||||||
{
|
{
|
||||||
UseSharedRedisCache = false,
|
UseSharedDistributedCache = false,
|
||||||
Redis = new GlobalSettings.ConnectionStringSettings
|
Redis = new GlobalSettings.ConnectionStringSettings
|
||||||
{
|
{
|
||||||
ConnectionString = "localhost:6379,ssl=false"
|
ConnectionString = "localhost:6379,ssl=false"
|
||||||
|
|||||||
@@ -18,9 +18,12 @@ public static class ExtendedCacheServiceCollectionExtensions
|
|||||||
/// Adds a new, named Fusion Cache <see href="https://github.com/ZiggyCreatures/FusionCache"/> to the service
|
/// 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/>
|
/// collection. If an existing cache of the same name is found, it will do nothing.<br/>
|
||||||
/// <br/>
|
/// <br/>
|
||||||
/// <b>Note</b>: When re-using the existing Redis cache, it is expected to call this method <b>after</b> calling
|
/// <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,
|
/// <code>services.AddDistributedCache(globalSettings)</code><br />This ensures that DI correctly finds
|
||||||
/// configures, and re-uses all the shared Redis architecture.
|
/// 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>
|
/// </summary>
|
||||||
public static IServiceCollection AddExtendedCache(
|
public static IServiceCollection AddExtendedCache(
|
||||||
this IServiceCollection services,
|
this IServiceCollection services,
|
||||||
@@ -72,12 +75,21 @@ public static class ExtendedCacheServiceCollectionExtensions
|
|||||||
if (!settings.EnableDistributedCache)
|
if (!settings.EnableDistributedCache)
|
||||||
return services;
|
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))
|
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;
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Using Shared Redis, TryAdd and reuse all pieces (multiplexer, distributed cache and backplane)
|
||||||
|
|
||||||
services.TryAddSingleton<IConnectionMultiplexer>(sp =>
|
services.TryAddSingleton<IConnectionMultiplexer>(sp =>
|
||||||
CreateConnectionMultiplexer(sp, cacheName, globalSettings.DistributedCache.Redis.ConnectionString));
|
CreateConnectionMultiplexer(sp, cacheName, globalSettings.DistributedCache.Redis.ConnectionString));
|
||||||
@@ -107,10 +119,21 @@ public static class ExtendedCacheServiceCollectionExtensions
|
|||||||
return services;
|
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))
|
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;
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Using Keyed Redis: TryAdd and reuse all pieces (multiplexer, distributed cache and backplane)
|
||||||
|
|
||||||
services.TryAddKeyedSingleton<IConnectionMultiplexer>(
|
services.TryAddKeyedSingleton<IConnectionMultiplexer>(
|
||||||
cacheName,
|
cacheName,
|
||||||
|
|||||||
@@ -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)
|
public async Task<ProviderUser?> GetByProviderUserAsync(Guid providerId, Guid userId)
|
||||||
{
|
{
|
||||||
using (var connection = new SqlConnection(ConnectionString))
|
using (var connection = new SqlConnection(ConnectionString))
|
||||||
|
|||||||
@@ -96,6 +96,20 @@ public class ProviderUserRepository :
|
|||||||
return await query.ToArrayAsync();
|
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)
|
public async Task<ProviderUser> GetByProviderUserAsync(Guid providerId, Guid userId)
|
||||||
{
|
{
|
||||||
using (var scope = ServiceScopeFactory.CreateScope())
|
using (var scope = ServiceScopeFactory.CreateScope())
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,39 +1,593 @@
|
|||||||
using System.Net;
|
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.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 Bit.Seeder.Recipes;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using Xunit.Abstractions;
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
namespace Bit.Api.IntegrationTest.AdminConsole.Controllers;
|
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")]
|
[Theory(Skip = "Performance test")]
|
||||||
[InlineData(100)]
|
[InlineData(10)]
|
||||||
[InlineData(60000)]
|
//[InlineData(100)]
|
||||||
public async Task GetAsync(int seats)
|
//[InlineData(1000)]
|
||||||
|
public async Task GetAllUsers_WithCollections(int seats)
|
||||||
{
|
{
|
||||||
await using var factory = new SqlServerApiApplicationFactory();
|
await using var factory = new SqlServerApiApplicationFactory();
|
||||||
var client = factory.CreateClient();
|
var client = factory.CreateClient();
|
||||||
|
|
||||||
var db = factory.GetDatabaseContext();
|
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=");
|
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: seats);
|
||||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Token);
|
|
||||||
|
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 stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
|
||||||
var response = await client.GetAsync($"/organizations/{orgId}/users?includeCollections=true");
|
var response = await client.GetAsync($"/organizations/{orgId}/users?includeCollections=true");
|
||||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
|
||||||
var result = await response.Content.ReadAsStringAsync();
|
stopwatch.Stop();
|
||||||
Assert.NotEmpty(result);
|
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();
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -192,6 +192,15 @@ public static class OrganizationTestHelpers
|
|||||||
await policyRepository.CreateAsync(policy);
|
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>
|
/// <summary>
|
||||||
/// Creates a user account without a Master Password and adds them as a member to the specified organization.
|
/// Creates a user account without a Master Password and adds them as a member to the specified organization.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
32
test/Api.IntegrationTest/Helpers/PerformanceTestHelpers.cs
Normal file
32
test/Api.IntegrationTest/Helpers/PerformanceTestHelpers.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,52 +21,23 @@ namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyValidat
|
|||||||
public class AutomaticUserConfirmationPolicyEventHandlerTests
|
public class AutomaticUserConfirmationPolicyEventHandlerTests
|
||||||
{
|
{
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task ValidateAsync_EnablingPolicy_SingleOrgNotEnabled_ReturnsError(
|
public void RequiredPolicies_IncludesSingleOrg(
|
||||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
|
||||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||||
{
|
{
|
||||||
// Arrange
|
|
||||||
sutProvider.GetDependency<IPolicyRepository>()
|
|
||||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
|
|
||||||
.Returns((Policy?)null);
|
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
|
var requiredPolicies = sutProvider.Sut.RequiredPolicies;
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Contains("Single organization policy must be enabled", result, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains(PolicyType.SingleOrg, requiredPolicies);
|
||||||
}
|
|
||||||
|
|
||||||
[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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task ValidateAsync_EnablingPolicy_UsersNotCompliantWithSingleOrg_ReturnsError(
|
public async Task ValidateAsync_EnablingPolicy_UsersNotCompliantWithSingleOrg_ReturnsError(
|
||||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||||
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
|
|
||||||
Guid nonCompliantUserId,
|
Guid nonCompliantUserId,
|
||||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
|
|
||||||
|
|
||||||
var orgUser = new OrganizationUserUserDetails
|
var orgUser = new OrganizationUserUserDetails
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
@@ -85,10 +56,6 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
|||||||
Status = OrganizationUserStatusType.Confirmed
|
Status = OrganizationUserStatusType.Confirmed
|
||||||
};
|
};
|
||||||
|
|
||||||
sutProvider.GetDependency<IPolicyRepository>()
|
|
||||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
|
|
||||||
.Returns(singleOrgPolicy);
|
|
||||||
|
|
||||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||||
.Returns([orgUser]);
|
.Returns([orgUser]);
|
||||||
@@ -107,13 +74,10 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
|||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task ValidateAsync_EnablingPolicy_UserWithInvitedStatusInOtherOrg_ValidationPasses(
|
public async Task ValidateAsync_EnablingPolicy_UserWithInvitedStatusInOtherOrg_ValidationPasses(
|
||||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||||
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
|
|
||||||
Guid userId,
|
Guid userId,
|
||||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
|
|
||||||
|
|
||||||
var orgUser = new OrganizationUserUserDetails
|
var orgUser = new OrganizationUserUserDetails
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
@@ -121,7 +85,6 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
|||||||
Type = OrganizationUserType.User,
|
Type = OrganizationUserType.User,
|
||||||
Status = OrganizationUserStatusType.Confirmed,
|
Status = OrganizationUserStatusType.Confirmed,
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
Email = "test@email.com"
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var otherOrgUser = new OrganizationUser
|
var otherOrgUser = new OrganizationUser
|
||||||
@@ -133,10 +96,6 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
|||||||
Email = orgUser.Email
|
Email = orgUser.Email
|
||||||
};
|
};
|
||||||
|
|
||||||
sutProvider.GetDependency<IPolicyRepository>()
|
|
||||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
|
|
||||||
.Returns(singleOrgPolicy);
|
|
||||||
|
|
||||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||||
.Returns([orgUser]);
|
.Returns([orgUser]);
|
||||||
@@ -146,7 +105,7 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
|||||||
.Returns([otherOrgUser]);
|
.Returns([otherOrgUser]);
|
||||||
|
|
||||||
sutProvider.GetDependency<IProviderUserRepository>()
|
sutProvider.GetDependency<IProviderUserRepository>()
|
||||||
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
|
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||||
.Returns([]);
|
.Returns([]);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -159,30 +118,37 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
|||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task ValidateAsync_EnablingPolicy_ProviderUsersExist_ReturnsError(
|
public async Task ValidateAsync_EnablingPolicy_ProviderUsersExist_ReturnsError(
|
||||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||||
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
|
Guid userId,
|
||||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||||
{
|
{
|
||||||
// Arrange
|
// 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
|
var providerUser = new ProviderUser
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
ProviderId = Guid.NewGuid(),
|
ProviderId = Guid.NewGuid(),
|
||||||
UserId = Guid.NewGuid(),
|
UserId = userId,
|
||||||
Status = ProviderUserStatusType.Confirmed
|
Status = ProviderUserStatusType.Confirmed
|
||||||
};
|
};
|
||||||
|
|
||||||
sutProvider.GetDependency<IPolicyRepository>()
|
|
||||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
|
|
||||||
.Returns(singleOrgPolicy);
|
|
||||||
|
|
||||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||||
|
.Returns([orgUser]);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||||
|
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||||
.Returns([]);
|
.Returns([]);
|
||||||
|
|
||||||
sutProvider.GetDependency<IProviderUserRepository>()
|
sutProvider.GetDependency<IProviderUserRepository>()
|
||||||
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
|
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||||
.Returns([providerUser]);
|
.Returns([providerUser]);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -196,26 +162,18 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
|||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task ValidateAsync_EnablingPolicy_AllValidationsPassed_ReturnsEmptyString(
|
public async Task ValidateAsync_EnablingPolicy_AllValidationsPassed_ReturnsEmptyString(
|
||||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||||
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
|
|
||||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
|
|
||||||
|
|
||||||
var orgUser = new OrganizationUserUserDetails
|
var orgUser = new OrganizationUserUserDetails
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
OrganizationId = policyUpdate.OrganizationId,
|
OrganizationId = policyUpdate.OrganizationId,
|
||||||
Type = OrganizationUserType.User,
|
Type = OrganizationUserType.User,
|
||||||
Status = OrganizationUserStatusType.Confirmed,
|
Status = OrganizationUserStatusType.Confirmed,
|
||||||
UserId = Guid.NewGuid(),
|
UserId = Guid.NewGuid()
|
||||||
Email = "user@example.com"
|
|
||||||
};
|
};
|
||||||
|
|
||||||
sutProvider.GetDependency<IPolicyRepository>()
|
|
||||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
|
|
||||||
.Returns(singleOrgPolicy);
|
|
||||||
|
|
||||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||||
.Returns([orgUser]);
|
.Returns([orgUser]);
|
||||||
@@ -225,7 +183,7 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
|||||||
.Returns([]);
|
.Returns([]);
|
||||||
|
|
||||||
sutProvider.GetDependency<IProviderUserRepository>()
|
sutProvider.GetDependency<IProviderUserRepository>()
|
||||||
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
|
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||||
.Returns([]);
|
.Returns([]);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -249,9 +207,10 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.True(string.IsNullOrEmpty(result));
|
Assert.True(string.IsNullOrEmpty(result));
|
||||||
await sutProvider.GetDependency<IPolicyRepository>()
|
|
||||||
|
await sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||||
.DidNotReceive()
|
.DidNotReceive()
|
||||||
.GetByOrganizationIdTypeAsync(Arg.Any<Guid>(), Arg.Any<PolicyType>());
|
.GetManyDetailsByOrganizationAsync(Arg.Any<Guid>());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
@@ -268,21 +227,18 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.True(string.IsNullOrEmpty(result));
|
Assert.True(string.IsNullOrEmpty(result));
|
||||||
await sutProvider.GetDependency<IPolicyRepository>()
|
await sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||||
.DidNotReceive()
|
.DidNotReceive()
|
||||||
.GetByOrganizationIdTypeAsync(Arg.Any<Guid>(), Arg.Any<PolicyType>());
|
.GetManyDetailsByOrganizationAsync(Arg.Any<Guid>());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task ValidateAsync_EnablingPolicy_IncludesOwnersAndAdmins_InComplianceCheck(
|
public async Task ValidateAsync_EnablingPolicy_IncludesOwnersAndAdmins_InComplianceCheck(
|
||||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||||
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
|
|
||||||
Guid nonCompliantOwnerId,
|
Guid nonCompliantOwnerId,
|
||||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
|
|
||||||
|
|
||||||
var ownerUser = new OrganizationUserUserDetails
|
var ownerUser = new OrganizationUserUserDetails
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
@@ -290,7 +246,6 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
|||||||
Type = OrganizationUserType.Owner,
|
Type = OrganizationUserType.Owner,
|
||||||
Status = OrganizationUserStatusType.Confirmed,
|
Status = OrganizationUserStatusType.Confirmed,
|
||||||
UserId = nonCompliantOwnerId,
|
UserId = nonCompliantOwnerId,
|
||||||
Email = "owner@example.com"
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var otherOrgUser = new OrganizationUser
|
var otherOrgUser = new OrganizationUser
|
||||||
@@ -301,10 +256,6 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
|||||||
Status = OrganizationUserStatusType.Confirmed
|
Status = OrganizationUserStatusType.Confirmed
|
||||||
};
|
};
|
||||||
|
|
||||||
sutProvider.GetDependency<IPolicyRepository>()
|
|
||||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
|
|
||||||
.Returns(singleOrgPolicy);
|
|
||||||
|
|
||||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||||
.Returns([ownerUser]);
|
.Returns([ownerUser]);
|
||||||
@@ -323,12 +274,9 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
|||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task ValidateAsync_EnablingPolicy_InvitedUsersExcluded_FromComplianceCheck(
|
public async Task ValidateAsync_EnablingPolicy_InvitedUsersExcluded_FromComplianceCheck(
|
||||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||||
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
|
|
||||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
|
|
||||||
|
|
||||||
var invitedUser = new OrganizationUserUserDetails
|
var invitedUser = new OrganizationUserUserDetails
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
@@ -339,16 +287,12 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
|||||||
Email = "invited@example.com"
|
Email = "invited@example.com"
|
||||||
};
|
};
|
||||||
|
|
||||||
sutProvider.GetDependency<IPolicyRepository>()
|
|
||||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
|
|
||||||
.Returns(singleOrgPolicy);
|
|
||||||
|
|
||||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||||
.Returns([invitedUser]);
|
.Returns([invitedUser]);
|
||||||
|
|
||||||
sutProvider.GetDependency<IProviderUserRepository>()
|
sutProvider.GetDependency<IProviderUserRepository>()
|
||||||
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
|
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||||
.Returns([]);
|
.Returns([]);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -359,14 +303,11 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task ValidateAsync_EnablingPolicy_RevokedUsersExcluded_FromComplianceCheck(
|
public async Task ValidateAsync_EnablingPolicy_RevokedUsersIncluded_InComplianceCheck(
|
||||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||||
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
|
|
||||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
|
|
||||||
|
|
||||||
var revokedUser = new OrganizationUserUserDetails
|
var revokedUser = new OrganizationUserUserDetails
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
@@ -374,38 +315,44 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
|||||||
Type = OrganizationUserType.User,
|
Type = OrganizationUserType.User,
|
||||||
Status = OrganizationUserStatusType.Revoked,
|
Status = OrganizationUserStatusType.Revoked,
|
||||||
UserId = Guid.NewGuid(),
|
UserId = Guid.NewGuid(),
|
||||||
Email = "revoked@example.com"
|
|
||||||
};
|
};
|
||||||
|
|
||||||
sutProvider.GetDependency<IPolicyRepository>()
|
var additionalOrgUser = new OrganizationUser
|
||||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
|
{
|
||||||
.Returns(singleOrgPolicy);
|
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)
|
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||||
.Returns([revokedUser]);
|
.Returns([revokedUser]);
|
||||||
|
|
||||||
|
orgUserRepository.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||||
|
.Returns([additionalOrgUser]);
|
||||||
|
|
||||||
sutProvider.GetDependency<IProviderUserRepository>()
|
sutProvider.GetDependency<IProviderUserRepository>()
|
||||||
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
|
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||||
.Returns([]);
|
.Returns([]);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
|
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.True(string.IsNullOrEmpty(result));
|
Assert.Contains("compliant with the Single organization policy", result, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task ValidateAsync_EnablingPolicy_AcceptedUsersIncluded_InComplianceCheck(
|
public async Task ValidateAsync_EnablingPolicy_AcceptedUsersIncluded_InComplianceCheck(
|
||||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||||
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
|
|
||||||
Guid nonCompliantUserId,
|
Guid nonCompliantUserId,
|
||||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
|
|
||||||
|
|
||||||
var acceptedUser = new OrganizationUserUserDetails
|
var acceptedUser = new OrganizationUserUserDetails
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
@@ -413,7 +360,6 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
|||||||
Type = OrganizationUserType.User,
|
Type = OrganizationUserType.User,
|
||||||
Status = OrganizationUserStatusType.Accepted,
|
Status = OrganizationUserStatusType.Accepted,
|
||||||
UserId = nonCompliantUserId,
|
UserId = nonCompliantUserId,
|
||||||
Email = "accepted@example.com"
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var otherOrgUser = new OrganizationUser
|
var otherOrgUser = new OrganizationUser
|
||||||
@@ -424,10 +370,6 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
|||||||
Status = OrganizationUserStatusType.Confirmed
|
Status = OrganizationUserStatusType.Confirmed
|
||||||
};
|
};
|
||||||
|
|
||||||
sutProvider.GetDependency<IPolicyRepository>()
|
|
||||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
|
|
||||||
.Returns(singleOrgPolicy);
|
|
||||||
|
|
||||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||||
.Returns([acceptedUser]);
|
.Returns([acceptedUser]);
|
||||||
@@ -443,186 +385,22 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
|||||||
Assert.Contains("compliant with the Single organization policy", result, StringComparison.OrdinalIgnoreCase);
|
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]
|
[Theory, BitAutoData]
|
||||||
public async Task ValidateAsync_WithSavePolicyModel_CallsValidateWithPolicyUpdate(
|
public async Task ValidateAsync_WithSavePolicyModel_CallsValidateWithPolicyUpdate(
|
||||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||||
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
|
|
||||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
|
|
||||||
|
|
||||||
var savePolicyModel = new SavePolicyModel(policyUpdate);
|
var savePolicyModel = new SavePolicyModel(policyUpdate);
|
||||||
|
|
||||||
sutProvider.GetDependency<IPolicyRepository>()
|
|
||||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
|
|
||||||
.Returns(singleOrgPolicy);
|
|
||||||
|
|
||||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||||
.Returns([]);
|
.Returns([]);
|
||||||
|
|
||||||
sutProvider.GetDependency<IProviderUserRepository>()
|
|
||||||
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
|
|
||||||
.Returns([]);
|
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, null);
|
var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, null);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.True(string.IsNullOrEmpty(result));
|
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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using NSubstitute;
|
|||||||
using StackExchange.Redis;
|
using StackExchange.Redis;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZiggyCreatures.Caching.Fusion;
|
using ZiggyCreatures.Caching.Fusion;
|
||||||
|
using ZiggyCreatures.Caching.Fusion.Backplane;
|
||||||
|
|
||||||
namespace Bit.Core.Test.Utilities;
|
namespace Bit.Core.Test.Utilities;
|
||||||
|
|
||||||
@@ -167,7 +168,7 @@ public class ExtendedCacheServiceCollectionExtensionsTests
|
|||||||
var settings = CreateGlobalSettings(new()
|
var settings = CreateGlobalSettings(new()
|
||||||
{
|
{
|
||||||
{ "GlobalSettings:DistributedCache:Redis:ConnectionString", "localhost:6379" },
|
{ "GlobalSettings:DistributedCache:Redis:ConnectionString", "localhost:6379" },
|
||||||
{ "GlobalSettings:DistributedCache:DefaultExtendedCache:UseSharedRedisCache", "true" }
|
{ "GlobalSettings:DistributedCache:DefaultExtendedCache:UseSharedDistributedCache", "true" }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Provide a multiplexer (shared)
|
// Provide a multiplexer (shared)
|
||||||
@@ -187,7 +188,7 @@ public class ExtendedCacheServiceCollectionExtensionsTests
|
|||||||
{
|
{
|
||||||
var settings = new GlobalSettings.ExtendedCacheSettings
|
var settings = new GlobalSettings.ExtendedCacheSettings
|
||||||
{
|
{
|
||||||
UseSharedRedisCache = false,
|
UseSharedDistributedCache = false,
|
||||||
Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "invalid:9999" }
|
Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "invalid:9999" }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -242,7 +243,7 @@ public class ExtendedCacheServiceCollectionExtensionsTests
|
|||||||
{
|
{
|
||||||
var settings = new GlobalSettings.ExtendedCacheSettings
|
var settings = new GlobalSettings.ExtendedCacheSettings
|
||||||
{
|
{
|
||||||
UseSharedRedisCache = false,
|
UseSharedDistributedCache = false,
|
||||||
// No Redis connection string
|
// No Redis connection string
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -261,13 +262,13 @@ public class ExtendedCacheServiceCollectionExtensionsTests
|
|||||||
var settingsA = new GlobalSettings.ExtendedCacheSettings
|
var settingsA = new GlobalSettings.ExtendedCacheSettings
|
||||||
{
|
{
|
||||||
EnableDistributedCache = true,
|
EnableDistributedCache = true,
|
||||||
UseSharedRedisCache = false,
|
UseSharedDistributedCache = false,
|
||||||
Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "localhost:6379" }
|
Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "localhost:6379" }
|
||||||
};
|
};
|
||||||
var settingsB = new GlobalSettings.ExtendedCacheSettings
|
var settingsB = new GlobalSettings.ExtendedCacheSettings
|
||||||
{
|
{
|
||||||
EnableDistributedCache = true,
|
EnableDistributedCache = true,
|
||||||
UseSharedRedisCache = false,
|
UseSharedDistributedCache = false,
|
||||||
Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "localhost:6380" }
|
Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "localhost:6380" }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -294,7 +295,7 @@ public class ExtendedCacheServiceCollectionExtensionsTests
|
|||||||
|
|
||||||
var settings = new GlobalSettings.ExtendedCacheSettings
|
var settings = new GlobalSettings.ExtendedCacheSettings
|
||||||
{
|
{
|
||||||
UseSharedRedisCache = false,
|
UseSharedDistributedCache = false,
|
||||||
Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "localhost:6379" }
|
Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "localhost:6379" }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -306,6 +307,180 @@ public class ExtendedCacheServiceCollectionExtensionsTests
|
|||||||
Assert.Same(existingCache, resolved);
|
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)
|
private static GlobalSettings CreateGlobalSettings(Dictionary<string, string?> data)
|
||||||
{
|
{
|
||||||
var config = new ConfigurationBuilder()
|
var config = new ConfigurationBuilder()
|
||||||
|
|||||||
@@ -89,6 +89,286 @@ public class ProviderUserRepositoryTests
|
|||||||
Assert.Equal(serializedSsoConfigData, orgWithSsoDetails.SsoConfig);
|
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(
|
private static void AssertProviderOrganizationDetails(
|
||||||
ProviderUserOrganizationDetails actual,
|
ProviderUserOrganizationDetails actual,
|
||||||
Organization expectedOrganization,
|
Organization expectedOrganization,
|
||||||
@@ -139,4 +419,6 @@ public class ProviderUserRepositoryTests
|
|||||||
Assert.Equal(expectedProviderUser.Status, actual.Status);
|
Assert.Equal(expectedProviderUser.Status, actual.Status);
|
||||||
Assert.Equal(expectedProviderUser.Type, actual.Type);
|
Assert.Equal(expectedProviderUser.Type, actual.Type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,6 @@ public class Program
|
|||||||
var db = scopedServices.GetRequiredService<DatabaseContext>();
|
var db = scopedServices.GetRequiredService<DatabaseContext>();
|
||||||
|
|
||||||
var recipe = new OrganizationWithUsersRecipe(db);
|
var recipe = new OrganizationWithUsersRecipe(db);
|
||||||
recipe.Seed(name, users, domain);
|
recipe.Seed(name: name, domain: domain, users: users);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -17,7 +17,31 @@ public class OrganizationSeeder
|
|||||||
Plan = "Enterprise (Annually)",
|
Plan = "Enterprise (Annually)",
|
||||||
PlanType = PlanType.EnterpriseAnnually,
|
PlanType = PlanType.EnterpriseAnnually,
|
||||||
Seats = seats,
|
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.
|
// 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.
|
// TODO: These should be dynamically generated by the SDK.
|
||||||
PublicKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmIJbGMk6eZqVE7UxhZ46Weu2jKciqOiOkSVYtGvs61rfe9AXxtLaaZEKN4d4DmkZcF6dna2eXNxZmb7U4pwlttye8ksqISe6IUAZQox7auBpjopdCEPhKRg3BD/u8ks9UxSxgWe+fpebjt6gd5hsl1/5HOObn7SeU6EEU04cp3/eH7a4OTdXxB8oN62HGV9kM/ubM1goILgjoSJDbihMK0eb7b8hPHwcA/YOgKKiu/N3FighccdSMD5Pk+HfjacsFNZQa2EsqW09IvvSZ+iL6HQeZ1vwc/6TO1J7EOfJZFQcjoEL9LVI693efYoMZSmrPEWziZ4PvwpOOGo6OObyMQIDAQAB",
|
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 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
|
return new OrganizationUser
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
OrganizationId = organization.Id,
|
OrganizationId = organization.Id,
|
||||||
UserId = user.Id,
|
UserId = isInvited ? null : user.Id,
|
||||||
|
Email = isInvited ? user.Email : null,
|
||||||
Key = "4.rY01mZFXHOsBAg5Fq4gyXuklWfm6mQASm42DJpx05a+e2mmp+P5W6r54WU2hlREX0uoTxyP91bKKwickSPdCQQ58J45LXHdr9t2uzOYyjVzpzebFcdMw1eElR9W2DW8wEk9+mvtWvKwu7yTebzND+46y1nRMoFydi5zPVLSlJEf81qZZ4Uh1UUMLwXz+NRWfixnGXgq2wRq1bH0n3mqDhayiG4LJKgGdDjWXC8W8MMXDYx24SIJrJu9KiNEMprJE+XVF9nQVNijNAjlWBqkDpsfaWTUfeVLRLctfAqW1blsmIv4RQ91PupYJZDNc8nO9ZTF3TEVM+2KHoxzDJrLs2Q==",
|
Key = isConfirmed ? "4.rY01mZFXHOsBAg5Fq4gyXuklWfm6mQASm42DJpx05a+e2mmp+P5W6r54WU2hlREX0uoTxyP91bKKwickSPdCQQ58J45LXHdr9t2uzOYyjVzpzebFcdMw1eElR9W2DW8wEk9+mvtWvKwu7yTebzND+46y1nRMoFydi5zPVLSlJEf81qZZ4Uh1UUMLwXz+NRWfixnGXgq2wRq1bH0n3mqDhayiG4LJKgGdDjWXC8W8MMXDYx24SIJrJu9KiNEMprJE+XVF9nQVNijNAjlWBqkDpsfaWTUfeVLRLctfAqW1blsmIv4RQ91PupYJZDNc8nO9ZTF3TEVM+2KHoxzDJrLs2Q==" : null,
|
||||||
Type = OrganizationUserType.Admin,
|
Type = type,
|
||||||
Status = OrganizationUserStatusType.Confirmed
|
Status = status
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
122
util/Seeder/Recipes/CollectionsRecipe.cs
Normal file
122
util/Seeder/Recipes/CollectionsRecipe.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
94
util/Seeder/Recipes/GroupsRecipe.cs
Normal file
94
util/Seeder/Recipes/GroupsRecipe.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
util/Seeder/Recipes/OrganizationDomainRecipe.cs
Normal file
25
util/Seeder/Recipes/OrganizationDomainRecipe.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.Infrastructure.EntityFramework.Repositories;
|
||||||
using Bit.Seeder.Factories;
|
using Bit.Seeder.Factories;
|
||||||
using LinqToDB.EntityFrameworkCore;
|
using LinqToDB.EntityFrameworkCore;
|
||||||
@@ -7,11 +8,12 @@ namespace Bit.Seeder.Recipes;
|
|||||||
|
|
||||||
public class OrganizationWithUsersRecipe(DatabaseContext db)
|
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 seats = Math.Max(users + 1, 1000);
|
||||||
var user = UserSeeder.CreateUser($"admin@{domain}");
|
var organization = OrganizationSeeder.CreateEnterprise(name, domain, seats);
|
||||||
var orgUser = organization.CreateOrganizationUser(user);
|
var ownerUser = UserSeeder.CreateUser($"owner@{domain}");
|
||||||
|
var ownerOrgUser = organization.CreateOrganizationUser(ownerUser, OrganizationUserType.Owner, OrganizationUserStatusType.Confirmed);
|
||||||
|
|
||||||
var additionalUsers = new List<User>();
|
var additionalUsers = new List<User>();
|
||||||
var additionalOrgUsers = new List<OrganizationUser>();
|
var additionalOrgUsers = new List<OrganizationUser>();
|
||||||
@@ -19,12 +21,12 @@ public class OrganizationWithUsersRecipe(DatabaseContext db)
|
|||||||
{
|
{
|
||||||
var additionalUser = UserSeeder.CreateUser($"user{i}@{domain}");
|
var additionalUser = UserSeeder.CreateUser($"user{i}@{domain}");
|
||||||
additionalUsers.Add(additionalUser);
|
additionalUsers.Add(additionalUser);
|
||||||
additionalOrgUsers.Add(organization.CreateOrganizationUser(additionalUser));
|
additionalOrgUsers.Add(organization.CreateOrganizationUser(additionalUser, OrganizationUserType.User, usersStatus));
|
||||||
}
|
}
|
||||||
|
|
||||||
db.Add(organization);
|
db.Add(organization);
|
||||||
db.Add(user);
|
db.Add(ownerUser);
|
||||||
db.Add(orgUser);
|
db.Add(ownerOrgUser);
|
||||||
|
|
||||||
db.SaveChanges();
|
db.SaveChanges();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user