-
Notifications
You must be signed in to change notification settings - Fork 1.5k
[PM-22236] Fix invited accounts stuck in intermediate claimed status #6810
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
eliykat
merged 10 commits into
main
from
ac/pm-22236/server-claimed-account-in-invited-state-cannot-be-deleted-without-cs-assistance
Jan 17, 2026
+606
โ455
Merged
Changes from 5 commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
8c68a2d
Exclude invited users from claimed domain check
eliykat eed9e78
Move tests to their own file
eliykat 7d1e8b9
Use helper methods
eliykat ec7b2b9
Fix list query
eliykat 570bfaa
dotnet format
eliykat ee4bb73
Merge remote-tracking branch 'origin/main' into ac/pm-22236/server-clโฆ
eliykat 9505309
bump dates
eliykat 96f25fd
Remove unnecessary select *
eliykat d9ca9df
Add comments
eliykat b2ae74d
Merge branch 'main' into ac/pm-22236/server-claimed-account-in-inviteโฆ
eliykat File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,4 +19,5 @@ BEGIN | |
| WHERE OD.[VerifiedDate] IS NOT NULL | ||
| AND CU.EmailDomain = OD.[DomainName] | ||
| AND O.[Enabled] = 1 | ||
| AND OU.[Status] != 0 | ||
|
||
| END | ||
335 changes: 335 additions & 0 deletions
335
...dminConsole/Repositories/OrganizationRepository/GetByVerifiedUserEmailDomainAsyncTests.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,335 @@ | ||
| ๏ปฟusing Bit.Core.Entities; | ||
| using Bit.Core.Enums; | ||
| using Bit.Core.Repositories; | ||
| using Xunit; | ||
|
|
||
| namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.OrganizationRepository; | ||
|
|
||
| public class GetByVerifiedUserEmailDomainAsyncTests | ||
| { | ||
| [Theory, DatabaseData] | ||
| public async Task GetByClaimedUserDomainAsync_WithVerifiedDomain_Success( | ||
| IUserRepository userRepository, | ||
| IOrganizationRepository organizationRepository, | ||
| IOrganizationUserRepository organizationUserRepository, | ||
| IOrganizationDomainRepository organizationDomainRepository) | ||
| { | ||
| var id = Guid.NewGuid(); | ||
| var domainName = $"{id}.example.com"; | ||
|
|
||
| var user1 = await userRepository.CreateAsync(new User | ||
| { | ||
| Name = "Test User 1", | ||
| Email = $"test+{id}@{domainName}", | ||
| ApiKey = "TEST", | ||
| SecurityStamp = "stamp", | ||
| Kdf = KdfType.PBKDF2_SHA256, | ||
| KdfIterations = 1, | ||
| KdfMemory = 2, | ||
| KdfParallelism = 3 | ||
| }); | ||
|
|
||
| var user2 = await userRepository.CreateAsync(new User | ||
| { | ||
| Name = "Test User 2", | ||
| Email = $"test+{id}@x-{domainName}", // Different domain | ||
| ApiKey = "TEST", | ||
| SecurityStamp = "stamp", | ||
| Kdf = KdfType.PBKDF2_SHA256, | ||
| KdfIterations = 1, | ||
| KdfMemory = 2, | ||
| KdfParallelism = 3 | ||
| }); | ||
|
|
||
| var user3 = await userRepository.CreateAsync(new User | ||
| { | ||
| Name = "Test User 2", | ||
| Email = $"test+{id}@{domainName}.example.com", // Different domain | ||
| ApiKey = "TEST", | ||
| SecurityStamp = "stamp", | ||
| Kdf = KdfType.PBKDF2_SHA256, | ||
| KdfIterations = 1, | ||
| KdfMemory = 2, | ||
| KdfParallelism = 3 | ||
| }); | ||
|
|
||
| var organization = await organizationRepository.CreateTestOrganizationAsync(); | ||
|
|
||
| var organizationDomain = new OrganizationDomain | ||
| { | ||
| OrganizationId = organization.Id, | ||
| DomainName = domainName, | ||
| Txt = "btw+12345", | ||
| }; | ||
| organizationDomain.SetVerifiedDate(); | ||
| organizationDomain.SetNextRunDate(12); | ||
| organizationDomain.SetJobRunCount(); | ||
| await organizationDomainRepository.CreateAsync(organizationDomain); | ||
|
|
||
| await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user1); | ||
| await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user2); | ||
| await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user3); | ||
|
|
||
| var user1Response = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user1.Id); | ||
| var user2Response = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user2.Id); | ||
| var user3Response = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user3.Id); | ||
|
|
||
| Assert.NotEmpty(user1Response); | ||
| Assert.Equal(organization.Id, user1Response.First().Id); | ||
| Assert.Empty(user2Response); | ||
| Assert.Empty(user3Response); | ||
| } | ||
|
|
||
| [Theory, 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.CreateTestOrganizationAsync(); | ||
|
|
||
| var organizationDomain = new OrganizationDomain | ||
| { | ||
| OrganizationId = organization.Id, | ||
| DomainName = domainName, | ||
| Txt = "btw+12345", | ||
| }; | ||
| organizationDomain.SetNextRunDate(12); | ||
| organizationDomain.SetJobRunCount(); | ||
| await organizationDomainRepository.CreateAsync(organizationDomain); | ||
|
|
||
| await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user); | ||
|
|
||
| var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user.Id); | ||
|
|
||
| Assert.Empty(result); | ||
| } | ||
|
|
||
| [Theory, 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.CreateTestOrganizationAsync(); | ||
| var organization2 = await organizationRepository.CreateTestOrganizationAsync(); | ||
|
|
||
| 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.CreateConfirmedTestOrganizationUserAsync(organization1, user); | ||
| await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization2, user); | ||
|
|
||
| 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); | ||
| } | ||
|
|
||
| [Theory, DatabaseData] | ||
| public async Task GetByVerifiedUserEmailDomainAsync_WithNonExistentUser_ReturnsEmpty( | ||
| IOrganizationRepository organizationRepository) | ||
| { | ||
| var nonExistentUserId = Guid.NewGuid(); | ||
|
|
||
| var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(nonExistentUserId); | ||
|
|
||
| Assert.Empty(result); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Tests an edge case where some invited users are created linked to a UserId. | ||
| /// This is defective behavior, but will take longer to fix - for now, we are defensive and expressly | ||
| /// exclude such users from the results without relying on the inner join only. | ||
| /// Invited-revoked users linked to a UserId remain intentionally unhandled for now as they have not caused | ||
| /// any issues to date and we want to minimize edge cases. | ||
| /// We will fix the underlying issue going forward: https://bitwarden.atlassian.net/browse/PM-22405 | ||
| /// </summary> | ||
| [Theory, DatabaseData] | ||
| public async Task GetByVerifiedUserEmailDomainAsync_WithInvitedUserWithUserId_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.CreateTestOrganizationAsync(); | ||
|
|
||
| var organizationDomain = new OrganizationDomain | ||
| { | ||
| OrganizationId = organization.Id, | ||
| DomainName = domainName, | ||
| Txt = "btw+12345", | ||
| }; | ||
| organizationDomain.SetVerifiedDate(); | ||
| organizationDomain.SetNextRunDate(12); | ||
| organizationDomain.SetJobRunCount(); | ||
| await organizationDomainRepository.CreateAsync(organizationDomain); | ||
|
|
||
| // Create invited user with matching email domain but UserId set (edge case) | ||
| await organizationUserRepository.CreateAsync(new OrganizationUser | ||
| { | ||
| OrganizationId = organization.Id, | ||
| UserId = user.Id, | ||
| Email = user.Email, | ||
| Status = OrganizationUserStatusType.Invited, | ||
| }); | ||
|
|
||
| var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user.Id); | ||
|
|
||
| // Invited users should be excluded even if they have UserId set | ||
| Assert.Empty(result); | ||
| } | ||
|
|
||
| [Theory, DatabaseData] | ||
| public async Task GetByVerifiedUserEmailDomainAsync_WithAcceptedUser_ReturnsOrganization( | ||
| 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.CreateTestOrganizationAsync(); | ||
|
|
||
| var organizationDomain = new OrganizationDomain | ||
| { | ||
| OrganizationId = organization.Id, | ||
| DomainName = domainName, | ||
| Txt = "btw+12345", | ||
| }; | ||
| organizationDomain.SetVerifiedDate(); | ||
| organizationDomain.SetNextRunDate(12); | ||
| organizationDomain.SetJobRunCount(); | ||
| await organizationDomainRepository.CreateAsync(organizationDomain); | ||
|
|
||
| await organizationUserRepository.CreateAcceptedTestOrganizationUserAsync(organization, user); | ||
|
|
||
| var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user.Id); | ||
|
|
||
| Assert.NotEmpty(result); | ||
| Assert.Equal(organization.Id, result.First().Id); | ||
| } | ||
|
|
||
| [Theory, DatabaseData] | ||
| public async Task GetByVerifiedUserEmailDomainAsync_WithRevokedUser_ReturnsOrganization( | ||
| 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.CreateTestOrganizationAsync(); | ||
|
|
||
| var organizationDomain = new OrganizationDomain | ||
| { | ||
| OrganizationId = organization.Id, | ||
| DomainName = domainName, | ||
| Txt = "btw+12345", | ||
| }; | ||
| organizationDomain.SetVerifiedDate(); | ||
| organizationDomain.SetNextRunDate(12); | ||
| organizationDomain.SetJobRunCount(); | ||
| await organizationDomainRepository.CreateAsync(organizationDomain); | ||
|
|
||
| await organizationUserRepository.CreateRevokedTestOrganizationUserAsync(organization, user); | ||
|
|
||
| var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user.Id); | ||
|
|
||
| Assert.NotEmpty(result); | ||
| Assert.Equal(organization.Id, result.First().Id); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just thinking out loud here lol:
Iโve noticed we have a few cases where we exclude certain collection types or user types. I agree that we should do filtering in the database to leverage the query optimizer and reduce sending unnecessary data over the network. However, I wonder if we could configure the sproc's parameters to include only the types we want returned. That way, we can control the types from the app in the future, reduce the need for an extra deployment, and avoid potential backward compatibility issues.
That said, this does add upfront complexity to the sproc for a situation that might never happen.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We do that in some sprocs, but here the status is closely tied to the purpose of the sproc: it's dealing with claimed accounts, and you can't claim an invited user's account. I would say this is a case where it should not be parameterized.