diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/GroupsControllerPerformanceTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/GroupsControllerPerformanceTests.cs new file mode 100644 index 0000000000..71c6bf104c --- /dev/null +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/GroupsControllerPerformanceTests.cs @@ -0,0 +1,63 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Bit.Api.AdminConsole.Models.Request; +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Api.Models.Request; +using Bit.Seeder.Recipes; +using Xunit; +using Xunit.Abstractions; + +namespace Bit.Api.IntegrationTest.AdminConsole.Controllers; + +public class GroupsControllerPerformanceTests(ITestOutputHelper testOutputHelper) +{ + /// + /// Tests PUT /organizations/{orgId}/groups/{id} + /// + [Theory(Skip = "Performance test")] + [InlineData(10, 5)] + //[InlineData(100, 10)] + //[InlineData(1000, 20)] + public async Task UpdateGroup_WithUsersAndCollections(int userCount, int collectionCount) + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + var collectionsSeeder = new CollectionsRecipe(db); + var groupsSeeder = new GroupsRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount); + + var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList(); + var collectionIds = collectionsSeeder.AddToOrganization(orgId, collectionCount, orgUserIds, 0); + var groupIds = groupsSeeder.AddToOrganization(orgId, 1, orgUserIds, 0); + + var groupId = groupIds.First(); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var updateRequest = new GroupRequestModel + { + Name = "Updated Group Name", + Collections = collectionIds.Select(c => new SelectionReadOnlyRequestModel { Id = c, ReadOnly = false, HidePasswords = false, Manage = false }), + Users = orgUserIds + }; + + var requestContent = new StringContent(JsonSerializer.Serialize(updateRequest), Encoding.UTF8, "application/json"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.PutAsync($"/organizations/{orgId}/groups/{groupId}", requestContent); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"PUT /organizations/{{orgId}}/groups/{{id}} - Users: {orgUserIds.Count}; Collections: {collectionIds.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +} diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs index d77a41f52e..fc64930777 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs @@ -1,39 +1,593 @@ using System.Net; -using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Api.Models.Request; +using Bit.Core.Enums; +using Bit.Core.Models.Data; using Bit.Seeder.Recipes; using Xunit; using Xunit.Abstractions; namespace Bit.Api.IntegrationTest.AdminConsole.Controllers; -public class OrganizationUsersControllerPerformanceTest(ITestOutputHelper testOutputHelper) +public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testOutputHelper) { + /// + /// Tests GET /organizations/{orgId}/users?includeCollections=true + /// [Theory(Skip = "Performance test")] - [InlineData(100)] - [InlineData(60000)] - public async Task GetAsync(int seats) + [InlineData(10)] + //[InlineData(100)] + //[InlineData(1000)] + public async Task GetAllUsers_WithCollections(int seats) { await using var factory = new SqlServerApiApplicationFactory(); var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var seeder = new OrganizationWithUsersRecipe(db); + var orgSeeder = new OrganizationWithUsersRecipe(db); + var collectionsSeeder = new CollectionsRecipe(db); + var groupsSeeder = new GroupsRecipe(db); - var orgId = seeder.Seed("Org", seats, "large.test"); + var domain = OrganizationTestHelpers.GenerateRandomDomain(); - var tokens = await factory.LoginAsync("admin@large.test", "c55hlJ/cfdvTd4awTXUqow6X3cOQCfGwn11o3HblnPs="); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Token); + var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: seats); + + var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList(); + collectionsSeeder.AddToOrganization(orgId, 10, orgUserIds); + groupsSeeder.AddToOrganization(orgId, 5, orgUserIds); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); var stopwatch = System.Diagnostics.Stopwatch.StartNew(); var response = await client.GetAsync($"/organizations/{orgId}/users?includeCollections=true"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var result = await response.Content.ReadAsStringAsync(); - Assert.NotEmpty(result); + stopwatch.Stop(); + testOutputHelper.WriteLine($"GET /users - Seats: {seats}; Request duration: {stopwatch.ElapsedMilliseconds} ms"); + } + + /// + /// Tests GET /organizations/{orgId}/users/mini-details + /// + [Theory(Skip = "Performance test")] + [InlineData(10)] + //[InlineData(100)] + //[InlineData(1000)] + public async Task GetAllUsers_MiniDetails(int seats) + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + var collectionsSeeder = new CollectionsRecipe(db); + var groupsSeeder = new GroupsRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: seats); + + var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList(); + collectionsSeeder.AddToOrganization(orgId, 10, orgUserIds); + groupsSeeder.AddToOrganization(orgId, 5, orgUserIds); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.GetAsync($"/organizations/{orgId}/users/mini-details"); stopwatch.Stop(); - testOutputHelper.WriteLine($"Seed: {seats}; Request duration: {stopwatch.ElapsedMilliseconds} ms"); + + testOutputHelper.WriteLine($"GET /users/mini-details - Seats: {seats}; Request duration: {stopwatch.ElapsedMilliseconds} ms"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + /// + /// Tests GET /organizations/{orgId}/users/{id}?includeGroups=true + /// + [Fact(Skip = "Performance test")] + public async Task GetSingleUser_WithGroups() + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + var groupsSeeder = new GroupsRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1); + + var orgUserId = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).FirstOrDefault(); + groupsSeeder.AddToOrganization(orgId, 2, [orgUserId]); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.GetAsync($"/organizations/{orgId}/users/{orgUserId}?includeGroups=true"); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"GET /users/{{id}} - Request duration: {stopwatch.ElapsedMilliseconds} ms"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + /// + /// Tests GET /organizations/{orgId}/users/{id}/reset-password-details + /// + [Fact(Skip = "Performance test")] + public async Task GetResetPasswordDetails_ForSingleUser() + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1); + + var orgUserId = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).FirstOrDefault(); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.GetAsync($"/organizations/{orgId}/users/{orgUserId}/reset-password-details"); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"GET /users/{{id}}/reset-password-details - Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + /// + /// Tests POST /organizations/{orgId}/users/confirm + /// + [Theory(Skip = "Performance test")] + [InlineData(10)] + //[InlineData(100)] + //[InlineData(1000)] + public async Task BulkConfirmUsers(int userCount) + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed( + name: "Org", + domain: domain, + users: userCount, + usersStatus: OrganizationUserStatusType.Accepted); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var acceptedUserIds = db.OrganizationUsers + .Where(ou => ou.OrganizationId == orgId && ou.Status == OrganizationUserStatusType.Accepted) + .Select(ou => ou.Id) + .ToList(); + + var confirmRequest = new OrganizationUserBulkConfirmRequestModel + { + Keys = acceptedUserIds.Select(id => new OrganizationUserBulkConfirmRequestModelEntry { Id = id, Key = "test-key-" + id }), + DefaultUserCollectionName = "2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk=" + }; + + var requestContent = new StringContent(JsonSerializer.Serialize(confirmRequest), Encoding.UTF8, "application/json"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.PostAsync($"/organizations/{orgId}/users/confirm", requestContent); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"POST /users/confirm - Users: {acceptedUserIds.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.True(response.IsSuccessStatusCode); + } + + /// + /// Tests POST /organizations/{orgId}/users/remove + /// + [Theory(Skip = "Performance test")] + [InlineData(10)] + //[InlineData(100)] + //[InlineData(1000)] + public async Task BulkRemoveUsers(int userCount) + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var usersToRemove = db.OrganizationUsers + .Where(ou => ou.OrganizationId == orgId && ou.Type == OrganizationUserType.User) + .Select(ou => ou.Id) + .ToList(); + + var removeRequest = new OrganizationUserBulkRequestModel { Ids = usersToRemove }; + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var requestContent = new StringContent(JsonSerializer.Serialize(removeRequest), Encoding.UTF8, "application/json"); + + var response = await client.PostAsync($"/organizations/{orgId}/users/remove", requestContent); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"POST /users/remove - Users: {usersToRemove.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.True(response.IsSuccessStatusCode); + } + + /// + /// Tests PUT /organizations/{orgId}/users/revoke + /// + [Theory(Skip = "Performance test")] + [InlineData(10)] + //[InlineData(100)] + //[InlineData(1000)] + public async Task BulkRevokeUsers(int userCount) + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed( + name: "Org", + domain: domain, + users: userCount, + usersStatus: OrganizationUserStatusType.Confirmed); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var usersToRevoke = db.OrganizationUsers + .Where(ou => ou.OrganizationId == orgId && ou.Type == OrganizationUserType.User) + .Select(ou => ou.Id) + .ToList(); + + var revokeRequest = new OrganizationUserBulkRequestModel { Ids = usersToRevoke }; + + var requestContent = new StringContent(JsonSerializer.Serialize(revokeRequest), Encoding.UTF8, "application/json"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.PutAsync($"/organizations/{orgId}/users/revoke", requestContent); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"PUT /users/revoke - Users: {usersToRevoke.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.True(response.IsSuccessStatusCode); + } + + /// + /// Tests PUT /organizations/{orgId}/users/restore + /// + [Theory(Skip = "Performance test")] + [InlineData(10)] + //[InlineData(100)] + //[InlineData(1000)] + public async Task BulkRestoreUsers(int userCount) + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed( + name: "Org", + domain: domain, + users: userCount, + usersStatus: OrganizationUserStatusType.Revoked); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var usersToRestore = db.OrganizationUsers + .Where(ou => ou.OrganizationId == orgId && ou.Type == OrganizationUserType.User) + .Select(ou => ou.Id) + .ToList(); + + var restoreRequest = new OrganizationUserBulkRequestModel { Ids = usersToRestore }; + + var requestContent = new StringContent(JsonSerializer.Serialize(restoreRequest), Encoding.UTF8, "application/json"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.PutAsync($"/organizations/{orgId}/users/restore", requestContent); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"PUT /users/restore - Users: {usersToRestore.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.True(response.IsSuccessStatusCode); + } + + /// + /// Tests POST /organizations/{orgId}/users/delete-account + /// + [Theory(Skip = "Performance test")] + [InlineData(10)] + //[InlineData(100)] + //[InlineData(1000)] + public async Task BulkDeleteAccounts(int userCount) + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + var domainSeeder = new OrganizationDomainRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + + var orgId = orgSeeder.Seed( + name: "Org", + domain: domain, + users: userCount, + usersStatus: OrganizationUserStatusType.Confirmed); + + domainSeeder.AddVerifiedDomainToOrganization(orgId, domain); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var usersToDelete = db.OrganizationUsers + .Where(ou => ou.OrganizationId == orgId && ou.Type == OrganizationUserType.User) + .Select(ou => ou.Id) + .ToList(); + + var deleteRequest = new OrganizationUserBulkRequestModel { Ids = usersToDelete }; + + var requestContent = new StringContent(JsonSerializer.Serialize(deleteRequest), Encoding.UTF8, "application/json"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.PostAsync($"/organizations/{orgId}/users/delete-account", requestContent); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"POST /users/delete-account - Users: {usersToDelete.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.True(response.IsSuccessStatusCode); + } + + /// + /// Tests PUT /organizations/{orgId}/users/{id} + /// + [Fact(Skip = "Performance test")] + public async Task UpdateSingleUser_WithCollectionsAndGroups() + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + var collectionsSeeder = new CollectionsRecipe(db); + var groupsSeeder = new GroupsRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1); + + var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList(); + var collectionIds = collectionsSeeder.AddToOrganization(orgId, 3, orgUserIds, 0); + var groupIds = groupsSeeder.AddToOrganization(orgId, 2, orgUserIds, 0); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var userToUpdate = db.OrganizationUsers + .FirstOrDefault(ou => ou.OrganizationId == orgId && ou.Type == OrganizationUserType.User); + + var updateRequest = new OrganizationUserUpdateRequestModel + { + Type = OrganizationUserType.Custom, + Collections = collectionIds.Select(c => new SelectionReadOnlyRequestModel { Id = c, ReadOnly = false, HidePasswords = false, Manage = false }), + Groups = groupIds, + AccessSecretsManager = false, + Permissions = new Permissions { AccessEventLogs = true } + }; + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.PutAsync($"/organizations/{orgId}/users/{userToUpdate.Id}", + new StringContent(JsonSerializer.Serialize(updateRequest), Encoding.UTF8, "application/json")); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"PUT /users/{{id}} - Collections: {collectionIds.Count}; Groups: {groupIds.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.True(response.IsSuccessStatusCode); + } + + /// + /// Tests PUT /organizations/{orgId}/users/enable-secrets-manager + /// + [Theory(Skip = "Performance test")] + [InlineData(10)] + //[InlineData(100)] + //[InlineData(1000)] + public async Task BulkEnableSecretsManager(int userCount) + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var usersToEnable = db.OrganizationUsers + .Where(ou => ou.OrganizationId == orgId && ou.Type == OrganizationUserType.User) + .Select(ou => ou.Id) + .ToList(); + + var enableRequest = new OrganizationUserBulkRequestModel { Ids = usersToEnable }; + + var requestContent = new StringContent(JsonSerializer.Serialize(enableRequest), Encoding.UTF8, "application/json"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.PutAsync($"/organizations/{orgId}/users/enable-secrets-manager", requestContent); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"PUT /users/enable-secrets-manager - Users: {usersToEnable.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.True(response.IsSuccessStatusCode); + } + + /// + /// Tests DELETE /organizations/{orgId}/users/{id}/delete-account + /// + [Fact(Skip = "Performance test")] + public async Task DeleteSingleUserAccount_FromVerifiedDomain() + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + var domainSeeder = new OrganizationDomainRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed( + name: "Org", + domain: domain, + users: 2, + usersStatus: OrganizationUserStatusType.Confirmed); + + domainSeeder.AddVerifiedDomainToOrganization(orgId, domain); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var userToDelete = db.OrganizationUsers + .FirstOrDefault(ou => ou.OrganizationId == orgId && ou.Type == OrganizationUserType.User); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.DeleteAsync($"/organizations/{orgId}/users/{userToDelete.Id}/delete-account"); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"DELETE /users/{{id}}/delete-account - Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + /// + /// Tests POST /organizations/{orgId}/users/invite + /// + [Theory(Skip = "Performance test")] + [InlineData(1)] + //[InlineData(5)] + //[InlineData(20)] + public async Task InviteUsers(int emailCount) + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + var collectionsSeeder = new CollectionsRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1); + + var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList(); + var collectionIds = collectionsSeeder.AddToOrganization(orgId, 2, orgUserIds, 0); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var emails = Enumerable.Range(0, emailCount).Select(i => $"{i:D4}@{domain}").ToArray(); + var inviteRequest = new OrganizationUserInviteRequestModel + { + Emails = emails, + Type = OrganizationUserType.User, + AccessSecretsManager = false, + Collections = Array.Empty(), + Groups = Array.Empty(), + Permissions = null + }; + + var requestContent = new StringContent(JsonSerializer.Serialize(inviteRequest), Encoding.UTF8, "application/json"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.PostAsync($"/organizations/{orgId}/users/invite", requestContent); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"POST /users/invite - Emails: {emails.Length}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + /// + /// Tests POST /organizations/{orgId}/users/reinvite + /// + [Theory(Skip = "Performance test")] + [InlineData(10)] + //[InlineData(100)] + //[InlineData(1000)] + public async Task BulkReinviteUsers(int userCount) + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed( + name: "Org", + domain: domain, + users: userCount, + usersStatus: OrganizationUserStatusType.Invited); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var usersToReinvite = db.OrganizationUsers + .Where(ou => ou.OrganizationId == orgId && ou.Status == OrganizationUserStatusType.Invited) + .Select(ou => ou.Id) + .ToList(); + + var reinviteRequest = new OrganizationUserBulkRequestModel { Ids = usersToReinvite }; + + var requestContent = new StringContent(JsonSerializer.Serialize(reinviteRequest), Encoding.UTF8, "application/json"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.PostAsync($"/organizations/{orgId}/users/reinvite", requestContent); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"POST /users/reinvite - Users: {usersToReinvite.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.True(response.IsSuccessStatusCode); } } diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationsControllerPerformanceTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationsControllerPerformanceTests.cs new file mode 100644 index 0000000000..238a9a5d53 --- /dev/null +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationsControllerPerformanceTests.cs @@ -0,0 +1,163 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.Auth.Models.Request.Accounts; +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Core.AdminConsole.Models.Business.Tokenables; +using Bit.Core.Billing.Enums; +using Bit.Core.Tokens; +using Bit.Seeder.Recipes; +using Xunit; +using Xunit.Abstractions; + +namespace Bit.Api.IntegrationTest.AdminConsole.Controllers; + +public class OrganizationsControllerPerformanceTests(ITestOutputHelper testOutputHelper) +{ + /// + /// Tests DELETE /organizations/{id} with password verification + /// + [Theory(Skip = "Performance test")] + [InlineData(10, 5, 3)] + //[InlineData(100, 20, 10)] + //[InlineData(1000, 50, 25)] + public async Task DeleteOrganization_WithPasswordVerification(int userCount, int collectionCount, int groupCount) + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + var collectionsSeeder = new CollectionsRecipe(db); + var groupsSeeder = new GroupsRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount); + + var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList(); + collectionsSeeder.AddToOrganization(orgId, collectionCount, orgUserIds, 0); + groupsSeeder.AddToOrganization(orgId, groupCount, orgUserIds, 0); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var deleteRequest = new SecretVerificationRequestModel + { + MasterPasswordHash = "c55hlJ/cfdvTd4awTXUqow6X3cOQCfGwn11o3HblnPs=" + }; + + var request = new HttpRequestMessage(HttpMethod.Delete, $"/organizations/{orgId}") + { + Content = new StringContent(JsonSerializer.Serialize(deleteRequest), Encoding.UTF8, "application/json") + }; + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + + var response = await client.SendAsync(request); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"DELETE /organizations/{{id}} - Users: {userCount}; Collections: {collectionCount}; Groups: {groupCount}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + /// + /// Tests POST /organizations/{id}/delete-recover-token with token verification + /// + [Theory(Skip = "Performance test")] + [InlineData(10, 5, 3)] + //[InlineData(100, 20, 10)] + //[InlineData(1000, 50, 25)] + public async Task DeleteOrganization_WithTokenVerification(int userCount, int collectionCount, int groupCount) + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + var collectionsSeeder = new CollectionsRecipe(db); + var groupsSeeder = new GroupsRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount); + + var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList(); + collectionsSeeder.AddToOrganization(orgId, collectionCount, orgUserIds, 0); + groupsSeeder.AddToOrganization(orgId, groupCount, orgUserIds, 0); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var organization = db.Organizations.FirstOrDefault(o => o.Id == orgId); + Assert.NotNull(organization); + + var tokenFactory = factory.GetService>(); + var tokenable = new OrgDeleteTokenable(organization, 24); + var token = tokenFactory.Protect(tokenable); + + var deleteRequest = new OrganizationVerifyDeleteRecoverRequestModel + { + Token = token + }; + + var requestContent = new StringContent(JsonSerializer.Serialize(deleteRequest), Encoding.UTF8, "application/json"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.PostAsync($"/organizations/{orgId}/delete-recover-token", requestContent); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"POST /organizations/{{id}}/delete-recover-token - Users: {userCount}; Collections: {collectionCount}; Groups: {groupCount}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + /// + /// Tests POST /organizations/create-without-payment + /// + [Fact(Skip = "Performance test")] + public async Task CreateOrganization_WithoutPayment() + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var email = $"user@{OrganizationTestHelpers.GenerateRandomDomain()}"; + var masterPasswordHash = "c55hlJ/cfdvTd4awTXUqow6X3cOQCfGwn11o3HblnPs="; + + await factory.LoginWithNewAccount(email, masterPasswordHash); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, email, masterPasswordHash); + + var createRequest = new OrganizationNoPaymentCreateRequest + { + Name = "Test Organization", + BusinessName = "Test Business Name", + BillingEmail = email, + PlanType = PlanType.EnterpriseAnnually, + Key = "2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk=", + AdditionalSeats = 1, + AdditionalStorageGb = 1, + UseSecretsManager = true, + AdditionalSmSeats = 1, + AdditionalServiceAccounts = 2, + MaxAutoscaleSeats = 100, + PremiumAccessAddon = false, + CollectionName = "2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk=" + }; + + var requestContent = new StringContent(JsonSerializer.Serialize(createRequest), Encoding.UTF8, "application/json"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.PostAsync("/organizations/create-without-payment", requestContent); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"POST /organizations/create-without-payment - AdditionalSeats: {createRequest.AdditionalSeats}; AdditionalStorageGb: {createRequest.AdditionalStorageGb}; AdditionalSmSeats: {createRequest.AdditionalSmSeats}; AdditionalServiceAccounts: {createRequest.AdditionalServiceAccounts}; MaxAutoscaleSeats: {createRequest.MaxAutoscaleSeats}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +} diff --git a/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs b/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs index c23ebff736..bcde370b24 100644 --- a/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs +++ b/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs @@ -192,6 +192,15 @@ public static class OrganizationTestHelpers await policyRepository.CreateAsync(policy); } + /// + /// Generates a unique random domain name for testing purposes. + /// + /// A domain string like "a1b2c3d4.com" + public static string GenerateRandomDomain() + { + return $"{Guid.NewGuid().ToString("N").Substring(0, 8)}.com"; + } + /// /// Creates a user account without a Master Password and adds them as a member to the specified organization. /// diff --git a/test/Api.IntegrationTest/Helpers/PerformanceTestHelpers.cs b/test/Api.IntegrationTest/Helpers/PerformanceTestHelpers.cs new file mode 100644 index 0000000000..ca26266dfa --- /dev/null +++ b/test/Api.IntegrationTest/Helpers/PerformanceTestHelpers.cs @@ -0,0 +1,32 @@ +using System.Net.Http.Headers; +using Bit.Api.IntegrationTest.Factories; + +namespace Bit.Api.IntegrationTest.Helpers; + +/// +/// Helper methods for performance tests to reduce code duplication. +/// +public static class PerformanceTestHelpers +{ + /// + /// Standard password hash used across performance tests. + /// + public const string StandardPasswordHash = "c55hlJ/cfdvTd4awTXUqow6X3cOQCfGwn11o3HblnPs="; + + /// + /// Authenticates an HttpClient with a bearer token for the specified user. + /// + /// The application factory to use for login. + /// The HttpClient to authenticate. + /// The user's email address. + /// The user's master password hash. Defaults to StandardPasswordHash. + public static async Task AuthenticateClientAsync( + SqlServerApiApplicationFactory factory, + HttpClient client, + string email, + string? masterPasswordHash = null) + { + var tokens = await factory.LoginAsync(email, masterPasswordHash ?? StandardPasswordHash); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Token); + } +} diff --git a/util/DbSeederUtility/Program.cs b/util/DbSeederUtility/Program.cs index 2d75b31934..0b41c1a692 100644 --- a/util/DbSeederUtility/Program.cs +++ b/util/DbSeederUtility/Program.cs @@ -34,6 +34,6 @@ public class Program var db = scopedServices.GetRequiredService(); var recipe = new OrganizationWithUsersRecipe(db); - recipe.Seed(name, users, domain); + recipe.Seed(name: name, domain: domain, users: users); } } diff --git a/util/Seeder/Factories/OrganizationSeeder.cs b/util/Seeder/Factories/OrganizationSeeder.cs index f6f05d9525..012661501f 100644 --- a/util/Seeder/Factories/OrganizationSeeder.cs +++ b/util/Seeder/Factories/OrganizationSeeder.cs @@ -17,7 +17,31 @@ public class OrganizationSeeder Plan = "Enterprise (Annually)", PlanType = PlanType.EnterpriseAnnually, Seats = seats, - + UseCustomPermissions = true, + UseOrganizationDomains = true, + UseSecretsManager = true, + UseGroups = true, + UseDirectory = true, + UseEvents = true, + UseTotp = true, + Use2fa = true, + UseApi = true, + UseResetPassword = true, + UsePasswordManager = true, + UseAutomaticUserConfirmation = true, + SelfHost = true, + UsersGetPremium = true, + LimitCollectionCreation = true, + LimitCollectionDeletion = true, + LimitItemDeletion = true, + AllowAdminAccessToAllCollectionItems = true, + UseRiskInsights = true, + UseAdminSponsoredFamilies = true, + SyncSeats = true, + Status = OrganizationStatusType.Created, + //GatewayCustomerId = "example-customer-id", + //GatewaySubscriptionId = "example-subscription-id", + MaxStorageGb = 10, // Currently hardcoded to the values from https://github.com/bitwarden/sdk-internal/blob/main/crates/bitwarden-core/src/client/test_accounts.rs. // TODO: These should be dynamically generated by the SDK. PublicKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmIJbGMk6eZqVE7UxhZ46Weu2jKciqOiOkSVYtGvs61rfe9AXxtLaaZEKN4d4DmkZcF6dna2eXNxZmb7U4pwlttye8ksqISe6IUAZQox7auBpjopdCEPhKRg3BD/u8ks9UxSxgWe+fpebjt6gd5hsl1/5HOObn7SeU6EEU04cp3/eH7a4OTdXxB8oN62HGV9kM/ubM1goILgjoSJDbihMK0eb7b8hPHwcA/YOgKKiu/N3FighccdSMD5Pk+HfjacsFNZQa2EsqW09IvvSZ+iL6HQeZ1vwc/6TO1J7EOfJZFQcjoEL9LVI693efYoMZSmrPEWziZ4PvwpOOGo6OObyMQIDAQAB", @@ -28,17 +52,25 @@ public class OrganizationSeeder public static class OrgnaizationExtensions { - public static OrganizationUser CreateOrganizationUser(this Organization organization, User user) + /// + /// Creates an OrganizationUser with fields populated based on status. + /// For Invited status, only user.Email is used. For other statuses, user.Id is used. + /// + public static OrganizationUser CreateOrganizationUser( + this Organization organization, User user, OrganizationUserType type, OrganizationUserStatusType status) { + var isInvited = status == OrganizationUserStatusType.Invited; + var isConfirmed = status == OrganizationUserStatusType.Confirmed || status == OrganizationUserStatusType.Revoked; + return new OrganizationUser { Id = Guid.NewGuid(), OrganizationId = organization.Id, - UserId = user.Id, - - Key = "4.rY01mZFXHOsBAg5Fq4gyXuklWfm6mQASm42DJpx05a+e2mmp+P5W6r54WU2hlREX0uoTxyP91bKKwickSPdCQQ58J45LXHdr9t2uzOYyjVzpzebFcdMw1eElR9W2DW8wEk9+mvtWvKwu7yTebzND+46y1nRMoFydi5zPVLSlJEf81qZZ4Uh1UUMLwXz+NRWfixnGXgq2wRq1bH0n3mqDhayiG4LJKgGdDjWXC8W8MMXDYx24SIJrJu9KiNEMprJE+XVF9nQVNijNAjlWBqkDpsfaWTUfeVLRLctfAqW1blsmIv4RQ91PupYJZDNc8nO9ZTF3TEVM+2KHoxzDJrLs2Q==", - Type = OrganizationUserType.Admin, - Status = OrganizationUserStatusType.Confirmed + UserId = isInvited ? null : user.Id, + Email = isInvited ? user.Email : null, + Key = isConfirmed ? "4.rY01mZFXHOsBAg5Fq4gyXuklWfm6mQASm42DJpx05a+e2mmp+P5W6r54WU2hlREX0uoTxyP91bKKwickSPdCQQ58J45LXHdr9t2uzOYyjVzpzebFcdMw1eElR9W2DW8wEk9+mvtWvKwu7yTebzND+46y1nRMoFydi5zPVLSlJEf81qZZ4Uh1UUMLwXz+NRWfixnGXgq2wRq1bH0n3mqDhayiG4LJKgGdDjWXC8W8MMXDYx24SIJrJu9KiNEMprJE+XVF9nQVNijNAjlWBqkDpsfaWTUfeVLRLctfAqW1blsmIv4RQ91PupYJZDNc8nO9ZTF3TEVM+2KHoxzDJrLs2Q==" : null, + Type = type, + Status = status }; } diff --git a/util/Seeder/Recipes/CollectionsRecipe.cs b/util/Seeder/Recipes/CollectionsRecipe.cs new file mode 100644 index 0000000000..e0f9057418 --- /dev/null +++ b/util/Seeder/Recipes/CollectionsRecipe.cs @@ -0,0 +1,122 @@ +using Bit.Core.Enums; +using Bit.Core.Utilities; +using Bit.Infrastructure.EntityFramework.Repositories; +using LinqToDB.EntityFrameworkCore; + +namespace Bit.Seeder.Recipes; + +public class CollectionsRecipe(DatabaseContext db) +{ + /// + /// Adds collections to an organization and creates relationships between users and collections. + /// + /// The ID of the organization to add collections to. + /// The number of collections to add. + /// The IDs of the users to create relationships with. + /// The maximum number of users to create relationships with. + public List AddToOrganization(Guid organizationId, int collections, List organizationUserIds, int maxUsersWithRelationships = 1000) + { + var collectionList = CreateAndSaveCollections(organizationId, collections); + + if (collectionList.Any()) + { + CreateAndSaveCollectionUserRelationships(collectionList, organizationUserIds, maxUsersWithRelationships); + } + + return collectionList.Select(c => c.Id).ToList(); + } + + private List CreateAndSaveCollections(Guid organizationId, int count) + { + var collectionList = new List(); + + for (var i = 0; i < count; i++) + { + collectionList.Add(new Core.Entities.Collection + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organizationId, + Name = $"Collection {i + 1}", + Type = CollectionType.SharedCollection, + CreationDate = DateTime.UtcNow, + RevisionDate = DateTime.UtcNow + }); + } + + if (collectionList.Any()) + { + db.BulkCopy(collectionList); + } + + return collectionList; + } + + private void CreateAndSaveCollectionUserRelationships( + List collections, + List organizationUserIds, + int maxUsersWithRelationships) + { + if (!organizationUserIds.Any() || maxUsersWithRelationships <= 0) + { + return; + } + + var collectionUsers = BuildCollectionUserRelationships(collections, organizationUserIds, maxUsersWithRelationships); + + if (collectionUsers.Any()) + { + db.BulkCopy(collectionUsers); + } + } + + /// + /// Creates user-to-collection relationships with varied assignment patterns for realistic test data. + /// Each user gets 1-3 collections based on a rotating pattern. + /// + private List BuildCollectionUserRelationships( + List collections, + List organizationUserIds, + int maxUsersWithRelationships) + { + var maxRelationships = Math.Min(organizationUserIds.Count, maxUsersWithRelationships); + var collectionUsers = new List(); + + for (var i = 0; i < maxRelationships; i++) + { + var orgUserId = organizationUserIds[i]; + var userCollectionAssignments = CreateCollectionAssignmentsForUser(collections, orgUserId, i); + collectionUsers.AddRange(userCollectionAssignments); + } + + return collectionUsers; + } + + /// + /// Assigns collections to a user with varying permissions. + /// Pattern: 1-3 collections per user (cycles: 1, 2, 3, 1, 2, 3...). + /// First collection has Manage rights, subsequent ones are ReadOnly. + /// + private List CreateCollectionAssignmentsForUser( + List collections, + Guid organizationUserId, + int userIndex) + { + var assignments = new List(); + var userCollectionCount = (userIndex % 3) + 1; // Cycles through 1, 2, or 3 collections + + for (var j = 0; j < userCollectionCount; j++) + { + var collectionIndex = (userIndex + j) % collections.Count; // Distribute across available collections + assignments.Add(new Core.Entities.CollectionUser + { + CollectionId = collections[collectionIndex].Id, + OrganizationUserId = organizationUserId, + ReadOnly = j > 0, // First assignment gets write access + HidePasswords = false, + Manage = j == 0 // First assignment gets manage permissions + }); + } + + return assignments; + } +} diff --git a/util/Seeder/Recipes/GroupsRecipe.cs b/util/Seeder/Recipes/GroupsRecipe.cs new file mode 100644 index 0000000000..3c8156d921 --- /dev/null +++ b/util/Seeder/Recipes/GroupsRecipe.cs @@ -0,0 +1,94 @@ +using Bit.Core.Utilities; +using Bit.Infrastructure.EntityFramework.Repositories; +using LinqToDB.EntityFrameworkCore; + +namespace Bit.Seeder.Recipes; + +public class GroupsRecipe(DatabaseContext db) +{ + /// + /// Adds groups to an organization and creates relationships between users and groups. + /// + /// The ID of the organization to add groups to. + /// The number of groups to add. + /// The IDs of the users to create relationships with. + /// The maximum number of users to create relationships with. + public List AddToOrganization(Guid organizationId, int groups, List organizationUserIds, int maxUsersWithRelationships = 1000) + { + var groupList = CreateAndSaveGroups(organizationId, groups); + + if (groupList.Any()) + { + CreateAndSaveGroupUserRelationships(groupList, organizationUserIds, maxUsersWithRelationships); + } + + return groupList.Select(g => g.Id).ToList(); + } + + private List CreateAndSaveGroups(Guid organizationId, int count) + { + var groupList = new List(); + + for (var i = 0; i < count; i++) + { + groupList.Add(new Core.AdminConsole.Entities.Group + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organizationId, + Name = $"Group {i + 1}" + }); + } + + if (groupList.Any()) + { + db.BulkCopy(groupList); + } + + return groupList; + } + + private void CreateAndSaveGroupUserRelationships( + List groups, + List organizationUserIds, + int maxUsersWithRelationships) + { + if (!organizationUserIds.Any() || maxUsersWithRelationships <= 0) + { + return; + } + + var groupUsers = BuildGroupUserRelationships(groups, organizationUserIds, maxUsersWithRelationships); + + if (groupUsers.Any()) + { + db.BulkCopy(groupUsers); + } + } + + /// + /// Creates user-to-group relationships with distributed assignment patterns for realistic test data. + /// Each user is assigned to one group, distributed evenly across available groups. + /// + private List BuildGroupUserRelationships( + List groups, + List organizationUserIds, + int maxUsersWithRelationships) + { + var maxRelationships = Math.Min(organizationUserIds.Count, maxUsersWithRelationships); + var groupUsers = new List(); + + for (var i = 0; i < maxRelationships; i++) + { + var orgUserId = organizationUserIds[i]; + var groupIndex = i % groups.Count; // Round-robin distribution across groups + + groupUsers.Add(new Core.AdminConsole.Entities.GroupUser + { + GroupId = groups[groupIndex].Id, + OrganizationUserId = orgUserId + }); + } + + return groupUsers; + } +} diff --git a/util/Seeder/Recipes/OrganizationDomainRecipe.cs b/util/Seeder/Recipes/OrganizationDomainRecipe.cs new file mode 100644 index 0000000000..b62dd5115e --- /dev/null +++ b/util/Seeder/Recipes/OrganizationDomainRecipe.cs @@ -0,0 +1,25 @@ +using Bit.Infrastructure.EntityFramework.Models; +using Bit.Infrastructure.EntityFramework.Repositories; + +namespace Bit.Seeder.Recipes; + +public class OrganizationDomainRecipe(DatabaseContext db) +{ + public void AddVerifiedDomainToOrganization(Guid organizationId, string domainName) + { + var domain = new OrganizationDomain + { + Id = Guid.NewGuid(), + OrganizationId = organizationId, + DomainName = domainName, + Txt = Guid.NewGuid().ToString("N"), + CreationDate = DateTime.UtcNow, + }; + + domain.SetVerifiedDate(); + domain.SetLastCheckedDate(); + + db.Add(domain); + db.SaveChanges(); + } +} diff --git a/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs b/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs index fb06c091ae..7678c3a9ce 100644 --- a/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs +++ b/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs @@ -1,4 +1,5 @@ -using Bit.Infrastructure.EntityFramework.Models; +using Bit.Core.Enums; +using Bit.Infrastructure.EntityFramework.Models; using Bit.Infrastructure.EntityFramework.Repositories; using Bit.Seeder.Factories; using LinqToDB.EntityFrameworkCore; @@ -7,11 +8,12 @@ namespace Bit.Seeder.Recipes; public class OrganizationWithUsersRecipe(DatabaseContext db) { - public Guid Seed(string name, int users, string domain) + public Guid Seed(string name, string domain, int users, OrganizationUserStatusType usersStatus = OrganizationUserStatusType.Confirmed) { - var organization = OrganizationSeeder.CreateEnterprise(name, domain, users); - var user = UserSeeder.CreateUser($"admin@{domain}"); - var orgUser = organization.CreateOrganizationUser(user); + var seats = Math.Max(users + 1, 1000); + var organization = OrganizationSeeder.CreateEnterprise(name, domain, seats); + var ownerUser = UserSeeder.CreateUser($"owner@{domain}"); + var ownerOrgUser = organization.CreateOrganizationUser(ownerUser, OrganizationUserType.Owner, OrganizationUserStatusType.Confirmed); var additionalUsers = new List(); var additionalOrgUsers = new List(); @@ -19,12 +21,12 @@ public class OrganizationWithUsersRecipe(DatabaseContext db) { var additionalUser = UserSeeder.CreateUser($"user{i}@{domain}"); additionalUsers.Add(additionalUser); - additionalOrgUsers.Add(organization.CreateOrganizationUser(additionalUser)); + additionalOrgUsers.Add(organization.CreateOrganizationUser(additionalUser, OrganizationUserType.User, usersStatus)); } db.Add(organization); - db.Add(user); - db.Add(orgUser); + db.Add(ownerUser); + db.Add(ownerOrgUser); db.SaveChanges();