diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index 6bcf75b35..4c5a4028c 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -124,7 +124,11 @@ public class OrganizationsController : Controller var userId = _userService.GetProperUserId(User).Value; var organizations = await _organizationUserRepository.GetManyDetailsByUserAsync(userId, OrganizationUserStatusType.Confirmed); - var responses = organizations.Select(o => new ProfileOrganizationResponseModel(o)); + + var organizationManagingActiveUser = await _userService.GetOrganizationsManagingUserAsync(userId); + var organizationIdsManagingActiveUser = organizationManagingActiveUser.Select(o => o.Id); + + var responses = organizations.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsManagingActiveUser)); return new ListResponseModel(responses); } diff --git a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs index 17ebfc095..a573bfb8d 100644 --- a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs @@ -15,7 +15,10 @@ public class ProfileOrganizationResponseModel : ResponseModel { public ProfileOrganizationResponseModel(string str) : base(str) { } - public ProfileOrganizationResponseModel(OrganizationUserOrganizationDetails organization) : this("profileOrganization") + public ProfileOrganizationResponseModel( + OrganizationUserOrganizationDetails organization, + IEnumerable organizationIdsManagingUser) + : this("profileOrganization") { Id = organization.OrganizationId; Name = organization.Name; @@ -64,6 +67,7 @@ public class ProfileOrganizationResponseModel : ResponseModel AccessSecretsManager = organization.AccessSecretsManager; LimitCollectionCreationDeletion = organization.LimitCollectionCreationDeletion; AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; + UserIsManagedByOrganization = organizationIdsManagingUser.Contains(organization.OrganizationId); if (organization.SsoConfig != null) { @@ -122,4 +126,15 @@ public class ProfileOrganizationResponseModel : ResponseModel public bool AccessSecretsManager { get; set; } public bool LimitCollectionCreationDeletion { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; } + /// + /// Indicates if the organization manages the user. + /// + /// + /// An organization manages a user if the user's email domain is verified by the organization and the user is a member of it. + /// The organization must be enabled and able to have verified domains. + /// + /// + /// False if the Account Deprovisioning feature flag is disabled. + /// + public bool UserIsManagedByOrganization { get; set; } } diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index cf74460fc..a0c01752a 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -443,11 +443,11 @@ public class AccountsController : Controller var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user); - var managedByOrganizationId = await GetManagedByOrganizationIdAsync(user); + var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(user.Id); var response = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails, twoFactorEnabled, - hasPremiumFromOrg, managedByOrganizationId); + hasPremiumFromOrg, organizationIdsManagingActiveUser); return response; } @@ -457,7 +457,9 @@ public class AccountsController : Controller var userId = _userService.GetProperUserId(User); var organizationUserDetails = await _organizationUserRepository.GetManyDetailsByUserAsync(userId.Value, OrganizationUserStatusType.Confirmed); - var responseData = organizationUserDetails.Select(o => new ProfileOrganizationResponseModel(o)); + var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(userId.Value); + + var responseData = organizationUserDetails.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsManagingActiveUser)); return new ListResponseModel(responseData); } @@ -475,9 +477,9 @@ public class AccountsController : Controller var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user); - var managedByOrganizationId = await GetManagedByOrganizationIdAsync(user); + var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(user.Id); - var response = new ProfileResponseModel(user, null, null, null, twoFactorEnabled, hasPremiumFromOrg, managedByOrganizationId); + var response = new ProfileResponseModel(user, null, null, null, twoFactorEnabled, hasPremiumFromOrg, organizationIdsManagingActiveUser); return response; } @@ -494,9 +496,9 @@ public class AccountsController : Controller var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user); - var managedByOrganizationId = await GetManagedByOrganizationIdAsync(user); + var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(user.Id); - var response = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled, userHasPremiumFromOrganization, managedByOrganizationId); + var response = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsManagingActiveUser); return response; } @@ -647,9 +649,9 @@ public class AccountsController : Controller var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user); - var managedByOrganizationId = await GetManagedByOrganizationIdAsync(user); + var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(user.Id); - var profile = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled, userHasPremiumFromOrganization, managedByOrganizationId); + var profile = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsManagingActiveUser); return new PaymentResponseModel { UserProfile = profile, @@ -937,14 +939,9 @@ public class AccountsController : Controller } } - private async Task GetManagedByOrganizationIdAsync(User user) + private async Task> GetOrganizationIdsManagingUserAsync(Guid userId) { - if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)) - { - return null; - } - - var organizationManagingUser = await _userService.GetOrganizationManagingUserAsync(user.Id); - return organizationManagingUser?.Id; + var organizationManagingUser = await _userService.GetOrganizationsManagingUserAsync(userId); + return organizationManagingUser.Select(o => o.Id); } } diff --git a/src/Api/Billing/Controllers/OrganizationsController.cs b/src/Api/Billing/Controllers/OrganizationsController.cs index 5371186b1..75ae2fb89 100644 --- a/src/Api/Billing/Controllers/OrganizationsController.cs +++ b/src/Api/Billing/Controllers/OrganizationsController.cs @@ -201,7 +201,10 @@ public class OrganizationsController( var organizationDetails = await organizationUserRepository.GetDetailsByUserAsync(userId, organization.Id, OrganizationUserStatusType.Confirmed); - return new ProfileOrganizationResponseModel(organizationDetails); + var organizationManagingActiveUser = await userService.GetOrganizationsManagingUserAsync(userId); + var organizationIdsManagingActiveUser = organizationManagingActiveUser.Select(o => o.Id); + + return new ProfileOrganizationResponseModel(organizationDetails, organizationIdsManagingActiveUser); } [HttpPost("{id:guid}/seat")] diff --git a/src/Api/Models/Response/ProfileResponseModel.cs b/src/Api/Models/Response/ProfileResponseModel.cs index f5d0382e5..a6ed4ebfa 100644 --- a/src/Api/Models/Response/ProfileResponseModel.cs +++ b/src/Api/Models/Response/ProfileResponseModel.cs @@ -15,7 +15,7 @@ public class ProfileResponseModel : ResponseModel IEnumerable providerUserOrganizationDetails, bool twoFactorEnabled, bool premiumFromOrganization, - Guid? managedByOrganizationId) : base("profile") + IEnumerable organizationIdsManagingUser) : base("profile") { if (user == null) { @@ -37,11 +37,10 @@ public class ProfileResponseModel : ResponseModel UsesKeyConnector = user.UsesKeyConnector; AvatarColor = user.AvatarColor; CreationDate = user.CreationDate; - Organizations = organizationsUserDetails?.Select(o => new ProfileOrganizationResponseModel(o)); + Organizations = organizationsUserDetails?.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsManagingUser)); Providers = providerUserDetails?.Select(p => new ProfileProviderResponseModel(p)); ProviderOrganizations = providerUserOrganizationDetails?.Select(po => new ProfileProviderOrganizationResponseModel(po)); - ManagedByOrganizationId = managedByOrganizationId; } public ProfileResponseModel() : base("profile") @@ -63,7 +62,6 @@ public class ProfileResponseModel : ResponseModel public bool UsesKeyConnector { get; set; } public string AvatarColor { get; set; } public DateTime CreationDate { get; set; } - public Guid? ManagedByOrganizationId { get; set; } public IEnumerable Organizations { get; set; } public IEnumerable Providers { get; set; } public IEnumerable ProviderOrganizations { get; set; } diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 769ba34a1..09ade4d0d 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -910,6 +910,13 @@ public class CiphersController : Controller throw new BadRequestException(ModelState); } + // If Account Deprovisioning is enabled, we need to check if the user is managed by any organization. + if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) + && await _userService.IsManagedByAnyOrganizationAsync(user.Id)) + { + throw new BadRequestException("Cannot purge accounts owned by an organization. Contact your organization administrator for additional details."); + } + if (string.IsNullOrWhiteSpace(organizationId)) { await _cipherRepository.DeleteByUserIdAsync(user.Id); diff --git a/src/Api/Vault/Controllers/SyncController.cs b/src/Api/Vault/Controllers/SyncController.cs index 79c71bb87..853320ec6 100644 --- a/src/Api/Vault/Controllers/SyncController.cs +++ b/src/Api/Vault/Controllers/SyncController.cs @@ -1,5 +1,4 @@ using Bit.Api.Vault.Models.Response; -using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; @@ -7,7 +6,6 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data; -using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -95,23 +93,12 @@ public class SyncController : Controller var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user); - var managedByOrganizationId = await GetManagedByOrganizationIdAsync(user, organizationUserDetails); + var organizationManagingActiveUser = await _userService.GetOrganizationsManagingUserAsync(user.Id); + var organizationIdsManagingActiveUser = organizationManagingActiveUser.Select(o => o.Id); var response = new SyncResponseModel(_globalSettings, user, userTwoFactorEnabled, userHasPremiumFromOrganization, - managedByOrganizationId, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails, + organizationIdsManagingActiveUser, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails, folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends); return response; } - - private async Task GetManagedByOrganizationIdAsync(User user, IEnumerable organizationUserDetails) - { - if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) || - !organizationUserDetails.Any(o => o.Enabled && o.UseSso)) - { - return null; - } - - var organizationManagingUser = await _userService.GetOrganizationManagingUserAsync(user.Id); - return organizationManagingUser?.Id; - } } diff --git a/src/Api/Vault/Models/Response/SyncResponseModel.cs b/src/Api/Vault/Models/Response/SyncResponseModel.cs index 2170a5232..ce5f4562d 100644 --- a/src/Api/Vault/Models/Response/SyncResponseModel.cs +++ b/src/Api/Vault/Models/Response/SyncResponseModel.cs @@ -21,7 +21,7 @@ public class SyncResponseModel : ResponseModel User user, bool userTwoFactorEnabled, bool userHasPremiumFromOrganization, - Guid? managedByOrganizationId, + IEnumerable organizationIdsManagingUser, IEnumerable organizationUserDetails, IEnumerable providerUserDetails, IEnumerable providerUserOrganizationDetails, @@ -35,7 +35,7 @@ public class SyncResponseModel : ResponseModel : base("sync") { Profile = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails, - providerUserOrganizationDetails, userTwoFactorEnabled, userHasPremiumFromOrganization, managedByOrganizationId); + providerUserOrganizationDetails, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsManagingUser); Folders = folders.Select(f => new FolderResponseModel(f)); Ciphers = ciphers.Select(c => new CipherDetailsResponseModel(c, globalSettings, collectionCiphersDict)); Collections = collections?.Select( diff --git a/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs index 9c14c4fbd..5b274d3f8 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs @@ -19,7 +19,7 @@ public interface IOrganizationRepository : IRepository Task> GetOwnerEmailAddressesById(Guid organizationId); /// - /// Gets the organization that has a claimed domain matching the user's email domain. + /// Gets the organizations that have a verified domain matching the user's email domain. /// - Task GetByClaimedUserDomainAsync(Guid userId); + Task> GetByVerifiedUserEmailDomainAsync(Guid userId); } diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index b15f5153e..65bec5ea9 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -90,14 +90,20 @@ public interface IUserService /// Indicates if the user is managed by any organization. /// /// - /// A managed user is a user whose email domain matches one of the Organization's verified domains. - /// The organization must be enabled and be on an Enterprise plan. + /// A user is considered managed by an organization if their email domain matches one of the verified domains of that organization, and the user is a member of it. + /// The organization must be enabled and able to have verified domains. /// + /// + /// False if the Account Deprovisioning feature flag is disabled. + /// Task IsManagedByAnyOrganizationAsync(Guid userId); /// - /// Gets the organization that manages the user. + /// Gets the organizations that manage the user. /// + /// + /// An empty collection if the Account Deprovisioning feature flag is disabled. + /// /// - Task GetOrganizationManagingUserAsync(Guid userId); + Task> GetOrganizationsManagingUserAsync(Guid userId); } diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 413437a59..f2e1d183d 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -1267,18 +1267,24 @@ public class UserService : UserManager, IUserService, IDisposable public async Task IsManagedByAnyOrganizationAsync(Guid userId) { - var managingOrganization = await GetOrganizationManagingUserAsync(userId); - return managingOrganization != null; + var managingOrganizations = await GetOrganizationsManagingUserAsync(userId); + return managingOrganizations.Any(); } - public async Task GetOrganizationManagingUserAsync(Guid userId) + public async Task> GetOrganizationsManagingUserAsync(Guid userId) { - // Users can only be managed by an Organization that is enabled and can have organization domains - var organization = await _organizationRepository.GetByClaimedUserDomainAsync(userId); + if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)) + { + return Enumerable.Empty(); + } + // Get all organizations that have verified the user's email domain. + var organizationsWithVerifiedUserEmailDomain = await _organizationRepository.GetByVerifiedUserEmailDomainAsync(userId); + + // Organizations must be enabled and able to have verified domains. // TODO: Replace "UseSso" with a new organization ability like "UseOrganizationDomains" (PM-11622). // Verified domains were tied to SSO, so we currently check the "UseSso" organization ability. - return (organization is { Enabled: true, UseSso: true }) ? organization : null; + return organizationsWithVerifiedUserEmailDomain.Where(organization => organization is { Enabled: true, UseSso: true }); } /// diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs index bdc2fb4ca..20fdf8315 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs @@ -168,7 +168,7 @@ public class OrganizationRepository : Repository, IOrganizat commandType: CommandType.StoredProcedure); } - public async Task GetByClaimedUserDomainAsync(Guid userId) + public async Task> GetByVerifiedUserEmailDomainAsync(Guid userId) { using (var connection = new SqlConnection(ConnectionString)) { @@ -177,7 +177,7 @@ public class OrganizationRepository : Repository, IOrganizat new { UserId = userId }, commandType: CommandType.StoredProcedure); - return result.SingleOrDefault(); + return result.ToList(); } } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs index 96c9a912e..bb9090e0a 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs @@ -276,7 +276,7 @@ public class OrganizationRepository : Repository GetByClaimedUserDomainAsync(Guid userId) + public async Task> GetByVerifiedUserEmailDomainAsync(Guid userId) { using (var scope = ServiceScopeFactory.CreateScope()) { @@ -291,7 +291,7 @@ public class OrganizationRepository : Repository sutProvider, Guid userId, Organization organization) + public async Task IsManagedByAnyOrganizationAsync_WithAccountDeprovisioningDisabled_ReturnsFalse( + SutProvider sutProvider, Guid userId) { - organization.Enabled = true; - organization.UseSso = true; - - sutProvider.GetDependency() - .GetByClaimedUserDomainAsync(userId) - .Returns(organization); - - var result = await sutProvider.Sut.IsManagedByAnyOrganizationAsync(userId); - Assert.True(result); - } - - [Theory, BitAutoData] - public async Task IsManagedByAnyOrganizationAsync_WithManagingDisabledOrganization_ReturnsFalse( - SutProvider sutProvider, Guid userId, Organization organization) - { - organization.Enabled = false; - organization.UseSso = true; - - sutProvider.GetDependency() - .GetByClaimedUserDomainAsync(userId) - .Returns(organization); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) + .Returns(false); var result = await sutProvider.Sut.IsManagedByAnyOrganizationAsync(userId); Assert.False(result); } [Theory, BitAutoData] - public async Task IsManagedByAnyOrganizationAsync_WithOrganizationUseSsoFalse_ReturnsFalse( + public async Task IsManagedByAnyOrganizationAsync_WithAccountDeprovisioningEnabled_WithManagingEnabledOrganization_ReturnsTrue( + SutProvider sutProvider, Guid userId, Organization organization) + { + organization.Enabled = true; + organization.UseSso = true; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) + .Returns(true); + + sutProvider.GetDependency() + .GetByVerifiedUserEmailDomainAsync(userId) + .Returns(new[] { organization }); + + var result = await sutProvider.Sut.IsManagedByAnyOrganizationAsync(userId); + Assert.True(result); + } + + [Theory, BitAutoData] + public async Task IsManagedByAnyOrganizationAsync_WithAccountDeprovisioningEnabled_WithManagingDisabledOrganization_ReturnsFalse( + SutProvider sutProvider, Guid userId, Organization organization) + { + organization.Enabled = false; + organization.UseSso = true; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) + .Returns(true); + + sutProvider.GetDependency() + .GetByVerifiedUserEmailDomainAsync(userId) + .Returns(new[] { organization }); + + var result = await sutProvider.Sut.IsManagedByAnyOrganizationAsync(userId); + Assert.False(result); + } + + [Theory, BitAutoData] + public async Task IsManagedByAnyOrganizationAsync_WithAccountDeprovisioningEnabled_WithOrganizationUseSsoFalse_ReturnsFalse( SutProvider sutProvider, Guid userId, Organization organization) { organization.Enabled = true; organization.UseSso = false; + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) + .Returns(true); + sutProvider.GetDependency() - .GetByClaimedUserDomainAsync(userId) - .Returns(organization); + .GetByVerifiedUserEmailDomainAsync(userId) + .Returns(new[] { organization }); var result = await sutProvider.Sut.IsManagedByAnyOrganizationAsync(userId); Assert.False(result); diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs index eac71e9c2..f6dc4a989 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs @@ -97,13 +97,160 @@ public class OrganizationRepositoryTests ResetPasswordKey = "resetpasswordkey1", }); - var user1Response = await organizationRepository.GetByClaimedUserDomainAsync(user1.Id); - var user2Response = await organizationRepository.GetByClaimedUserDomainAsync(user2.Id); - var user3Response = await organizationRepository.GetByClaimedUserDomainAsync(user3.Id); + var user1Response = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user1.Id); + var user2Response = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user2.Id); + var user3Response = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user3.Id); - Assert.NotNull(user1Response); - Assert.Equal(organization.Id, user1Response.Id); - Assert.Null(user2Response); - Assert.Null(user3Response); + Assert.NotEmpty(user1Response); + Assert.Equal(organization.Id, user1Response.First().Id); + Assert.Empty(user2Response); + Assert.Empty(user3Response); + } + + [DatabaseTheory, DatabaseData] + public async Task GetByVerifiedUserEmailDomainAsync_WithUnverifiedDomains_ReturnsEmpty( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationDomainRepository organizationDomainRepository) + { + var id = Guid.NewGuid(); + var domainName = $"{id}.example.com"; + + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{id}@{domainName}", + ApiKey = "TEST", + SecurityStamp = "stamp", + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 1, + KdfMemory = 2, + KdfParallelism = 3 + }); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = $"Test Org {id}", + BillingEmail = user.Email, + Plan = "Test", + PrivateKey = "privatekey", + }); + + var organizationDomain = new OrganizationDomain + { + OrganizationId = organization.Id, + DomainName = domainName, + Txt = "btw+12345", + }; + organizationDomain.SetNextRunDate(12); + organizationDomain.SetJobRunCount(); + await organizationDomainRepository.CreateAsync(organizationDomain); + + await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Confirmed, + ResetPasswordKey = "resetpasswordkey", + }); + + var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user.Id); + + Assert.Empty(result); + } + + [DatabaseTheory, DatabaseData] + public async Task GetByVerifiedUserEmailDomainAsync_WithMultipleVerifiedDomains_ReturnsAllMatchingOrganizations( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationDomainRepository organizationDomainRepository) + { + var id = Guid.NewGuid(); + var domainName = $"{id}.example.com"; + + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{id}@{domainName}", + ApiKey = "TEST", + SecurityStamp = "stamp", + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 1, + KdfMemory = 2, + KdfParallelism = 3 + }); + + var organization1 = await organizationRepository.CreateAsync(new Organization + { + Name = $"Test Org 1 {id}", + BillingEmail = user.Email, + Plan = "Test", + PrivateKey = "privatekey1", + }); + + var organization2 = await organizationRepository.CreateAsync(new Organization + { + Name = $"Test Org 2 {id}", + BillingEmail = user.Email, + Plan = "Test", + PrivateKey = "privatekey2", + }); + + var organizationDomain1 = new OrganizationDomain + { + OrganizationId = organization1.Id, + DomainName = domainName, + Txt = "btw+12345", + }; + organizationDomain1.SetNextRunDate(12); + organizationDomain1.SetJobRunCount(); + organizationDomain1.SetVerifiedDate(); + await organizationDomainRepository.CreateAsync(organizationDomain1); + + var organizationDomain2 = new OrganizationDomain + { + OrganizationId = organization2.Id, + DomainName = domainName, + Txt = "btw+67890", + }; + organizationDomain2.SetNextRunDate(12); + organizationDomain2.SetJobRunCount(); + organizationDomain2.SetVerifiedDate(); + await organizationDomainRepository.CreateAsync(organizationDomain2); + + await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization1.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Confirmed, + ResetPasswordKey = "resetpasswordkey1", + }); + + await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization2.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Confirmed, + ResetPasswordKey = "resetpasswordkey2", + }); + + var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user.Id); + + Assert.Equal(2, result.Count); + Assert.Contains(result, org => org.Id == organization1.Id); + Assert.Contains(result, org => org.Id == organization2.Id); + } + + [DatabaseTheory, DatabaseData] + public async Task GetByVerifiedUserEmailDomainAsync_WithNonExistentUser_ReturnsEmpty( + IOrganizationRepository organizationRepository) + { + var nonExistentUserId = Guid.NewGuid(); + + var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(nonExistentUserId); + + Assert.Empty(result); } }