1
0
mirror of https://github.com/bitwarden/server synced 2026-01-29 15:53:36 +00:00

Merge branch 'main' into billing/pm-29595/user-that-upgraded-from-premium-reverts-an-organization-upgrade-during-the-trial-period

This commit is contained in:
cyprain-okeke
2026-01-19 12:24:10 +01:00
committed by GitHub
23 changed files with 800 additions and 506 deletions

View File

@@ -23,11 +23,9 @@
}
},
"Logging": {
"IncludeScopes": false,
"LogLevel": {
"Default": "Debug",
"System": "Information",
"Microsoft": "Information"
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
},
"Console": {
"IncludeScopes": true,

View File

@@ -20,11 +20,9 @@
}
},
"Logging": {
"IncludeScopes": false,
"LogLevel": {
"Default": "Debug",
"System": "Information",
"Microsoft": "Information"
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
},
"Console": {
"IncludeScopes": true,

View File

@@ -23,11 +23,9 @@
}
},
"Logging": {
"IncludeScopes": false,
"LogLevel": {
"Default": "Debug",
"System": "Information",
"Microsoft": "Information"
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
},
"Console": {
"IncludeScopes": true,

View File

@@ -292,17 +292,24 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
.PreviousAttributes
.ToObject<Subscription>() as Subscription;
// Get all plan IDs that include Secrets Manager support to check if the organization has secret manager in the
// previous and/or current subscriptions.
var planIdsOfPlansWithSecretManager = (await _pricingClient.ListPlans())
.Where(orgPlan => orgPlan.SupportsSecretsManager && orgPlan.SecretsManager.StripeSeatPlanId != null)
.Select(orgPlan => orgPlan.SecretsManager.StripeSeatPlanId)
.ToHashSet();
// This being false doesn't necessarily mean that the organization doesn't subscribe to Secrets Manager.
// If there are changes to any subscription item, Stripe sends every item in the subscription, both
// changed and unchanged.
var previousSubscriptionHasSecretsManager =
previousSubscription?.Items is not null &&
previousSubscription.Items.Any(
previousSubscriptionItem => previousSubscriptionItem.Plan.Id == plan.SecretsManager.StripeSeatPlanId);
previousSubscriptionItem => planIdsOfPlansWithSecretManager.Contains(previousSubscriptionItem.Plan.Id));
var currentSubscriptionHasSecretsManager =
subscription.Items.Any(
currentSubscriptionItem => currentSubscriptionItem.Plan.Id == plan.SecretsManager.StripeSeatPlanId);
currentSubscriptionItem => planIdsOfPlansWithSecretManager.Contains(currentSubscriptionItem.Plan.Id));
if (!previousSubscriptionHasSecretsManager || currentSubscriptionHasSecretsManager)
{

View File

@@ -21,7 +21,9 @@ public interface IOrganizationRepository : IRepository<Organization, Guid>
Task<IEnumerable<string>> GetOwnerEmailAddressesById(Guid organizationId);
/// <summary>
/// Gets the organizations that have a verified domain matching the user's email domain.
/// Gets the organizations that have claimed the user's account. Currently, only one organization may claim a user.
/// This requires that the organization has claimed the user's domain and the user is an organization member.
/// It excludes invited members.
/// </summary>
Task<ICollection<Organization>> GetByVerifiedUserEmailDomainAsync(Guid userId);

View File

@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Hosting;
using System.Globalization;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
@@ -8,7 +9,7 @@ namespace Bit.Core.Utilities;
public static class LoggerFactoryExtensions
{
/// <summary>
///
///
/// </summary>
/// <param name="hostBuilder"></param>
/// <returns></returns>
@@ -21,10 +22,12 @@ public static class LoggerFactoryExtensions
return;
}
IConfiguration loggingConfiguration;
// If they have begun using the new settings location, use that
if (!string.IsNullOrEmpty(context.Configuration["Logging:PathFormat"]))
{
logging.AddFile(context.Configuration.GetSection("Logging"));
loggingConfiguration = context.Configuration.GetSection("Logging");
}
else
{
@@ -40,28 +43,35 @@ public static class LoggerFactoryExtensions
var projectName = loggingOptions.ProjectName
?? context.HostingEnvironment.ApplicationName;
string pathFormat;
if (loggingOptions.LogRollBySizeLimit.HasValue)
{
var pathFormat = loggingOptions.LogDirectoryByProject
pathFormat = loggingOptions.LogDirectoryByProject
? Path.Combine(loggingOptions.LogDirectory, projectName, "log.txt")
: Path.Combine(loggingOptions.LogDirectory, $"{projectName.ToLowerInvariant()}.log");
logging.AddFile(
pathFormat: pathFormat,
fileSizeLimitBytes: loggingOptions.LogRollBySizeLimit.Value
);
}
else
{
var pathFormat = loggingOptions.LogDirectoryByProject
pathFormat = loggingOptions.LogDirectoryByProject
? Path.Combine(loggingOptions.LogDirectory, projectName, "{Date}.txt")
: Path.Combine(loggingOptions.LogDirectory, $"{projectName.ToLowerInvariant()}_{{Date}}.log");
logging.AddFile(
pathFormat: pathFormat
);
}
// We want to rely on Serilog using the configuration section to have customization of the log levels
// so we make a custom configuration source for them based on the legacy values and allow overrides from
// the new location.
loggingConfiguration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
{"PathFormat", pathFormat},
{"FileSizeLimitBytes", loggingOptions.LogRollBySizeLimit?.ToString(CultureInfo.InvariantCulture)}
})
.AddConfiguration(context.Configuration.GetSection("Logging"))
.Build();
}
logging.AddFile(loggingConfiguration);
});
}

View File

@@ -17,11 +17,9 @@
}
},
"Logging": {
"IncludeScopes": false,
"LogLevel": {
"Default": "Debug",
"System": "Information",
"Microsoft": "Information"
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
},
"Console": {
"IncludeScopes": true,

View File

@@ -1,10 +1,8 @@
{
"Logging": {
"IncludeScopes": false,
"LogLevel": {
"Default": "Debug",
"System": "Information",
"Microsoft": "Information"
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
},
"Console": {
"IncludeScopes": true,

View File

@@ -17,11 +17,9 @@
}
},
"Logging": {
"IncludeScopes": false,
"LogLevel": {
"Default": "Debug",
"System": "Information",
"Microsoft": "Information"
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
},
"Console": {
"IncludeScopes": true,

View File

@@ -20,11 +20,9 @@
}
},
"Logging": {
"IncludeScopes": false,
"LogLevel": {
"Default": "Debug",
"System": "Information",
"Microsoft": "Information"
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
},
"Console": {
"IncludeScopes": true,

View File

@@ -325,7 +325,8 @@ public class OrganizationRepository : Repository<Core.AdminConsole.Entities.Orga
where ou.UserId == userWithDomain.UserId &&
od.DomainName == userWithDomain.EmailDomain &&
od.VerifiedDate != null &&
o.Enabled == true
o.Enabled == true &&
ou.Status != OrganizationUserStatusType.Invited
select o;
return await query.ToArrayAsync();

View File

@@ -1,4 +1,5 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
namespace Bit.Infrastructure.EntityFramework.Repositories.Queries;
@@ -16,6 +17,7 @@ public class OrganizationUserReadByClaimedOrganizationDomainsQuery : IQuery<Orga
var query = from ou in dbContext.OrganizationUsers
join u in dbContext.Users on ou.UserId equals u.Id
where ou.OrganizationId == _organizationId
&& ou.Status != OrganizationUserStatusType.Invited
&& dbContext.OrganizationDomains
.Any(od => od.OrganizationId == _organizationId &&
od.VerifiedDate != null &&

View File

@@ -17,11 +17,9 @@
}
},
"Logging": {
"IncludeScopes": false,
"LogLevel": {
"Default": "Debug",
"System": "Information",
"Microsoft": "Information"
"Default": "Information",
"Microsoft": "Warning"
},
"Console": {
"IncludeScopes": true,

View File

@@ -8,13 +8,14 @@ BEGIN
SELECT *
FROM [dbo].[OrganizationUserView]
WHERE [OrganizationId] = @OrganizationId
AND [Status] != 0 -- Exclude invited users
),
UserDomains AS (
SELECT U.[Id], U.[EmailDomain]
FROM [dbo].[UserEmailDomainView] U
WHERE EXISTS (
SELECT 1
FROM [dbo].[OrganizationDomainView] OD
FROM [dbo].[OrganizationDomainView] OD
WHERE OD.[OrganizationId] = @OrganizationId
AND OD.[VerifiedDate] IS NOT NULL
AND OD.[DomainName] = U.[EmailDomain]

View File

@@ -6,7 +6,7 @@ BEGIN
WITH CTE_User AS (
SELECT
U.*,
U.[Id],
SUBSTRING(U.Email, CHARINDEX('@', U.Email) + 1, LEN(U.Email)) AS EmailDomain
FROM dbo.[UserView] U
WHERE U.[Id] = @UserId
@@ -19,4 +19,5 @@ BEGIN
WHERE OD.[VerifiedDate] IS NOT NULL
AND CU.EmailDomain = OD.[DomainName]
AND O.[Enabled] = 1
AND OU.[Status] != 0 -- Exclude invited users
END

View File

@@ -12,6 +12,7 @@ using Bit.Core.Billing.Pricing;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Test.Billing.Mocks;
using Bit.Core.Test.Billing.Mocks.Plans;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json.Linq;
@@ -657,6 +658,8 @@ public class SubscriptionUpdatedHandlerTests
var plan = new Enterprise2023Plan(true);
_pricingClient.GetPlanOrThrow(organization.PlanType)
.Returns(plan);
_pricingClient.ListPlans()
.Returns(MockPlans.Plans);
var parsedEvent = new Event
{
@@ -696,6 +699,92 @@ public class SubscriptionUpdatedHandlerTests
await _stripeFacade.Received(1).DeleteCustomerDiscount(subscription.CustomerId);
await _stripeFacade.Received(1).DeleteSubscriptionDiscount(subscription.Id);
}
[Fact]
public async Task
HandleAsync_WhenUpgradingPlan_AndPreviousPlanHasSecretsManagerTrial_AndCurrentPlanHasSecretsManagerTrial_DoesNotRemovePasswordManagerCoupon()
{
// Arrange
var organizationId = Guid.NewGuid();
var subscription = new Subscription
{
Id = "sub_123",
Status = StripeSubscriptionStatus.Active,
CustomerId = "cus_123",
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(10),
Plan = new Plan { Id = "2023-enterprise-org-seat-annually" }
},
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(10),
Plan = new Plan { Id = "secrets-manager-enterprise-seat-annually" }
}
]
},
Customer = new Customer
{
Balance = 0,
Discount = new Discount { Coupon = new Coupon { Id = "sm-standalone" } }
},
Discounts = [new Discount { Coupon = new Coupon { Id = "sm-standalone" } }],
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } }
};
// Note: The organization plan is still the previous plan because the subscription is updated before the organization is updated
var organization = new Organization { Id = organizationId, PlanType = PlanType.TeamsAnnually2023 };
var plan = new Teams2023Plan(true);
_pricingClient.GetPlanOrThrow(organization.PlanType)
.Returns(plan);
_pricingClient.ListPlans()
.Returns(MockPlans.Plans);
var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(new
{
items = new
{
data = new[]
{
new { plan = new { id = "secrets-manager-teams-seat-annually" } },
}
},
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { Plan = new Stripe.Plan { Id = "secrets-manager-teams-seat-annually" } },
]
}
})
}
};
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(organizationId, null, null));
_organizationRepository.GetByIdAsync(organizationId)
.Returns(organization);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _stripeFacade.DidNotReceive().DeleteCustomerDiscount(subscription.CustomerId);
await _stripeFacade.DidNotReceive().DeleteSubscriptionDiscount(subscription.Id);
}
[Theory]
[MemberData(nameof(GetNonActiveSubscriptions))]

View File

@@ -74,8 +74,7 @@ public class LoggerFactoryExtensionsTests
logger.LogWarning("This is a test");
// Writing to the file is buffered, give it a little time to flush
await Task.Delay(5);
await provider.DisposeAsync();
var logFile = Assert.Single(tempDir.EnumerateFiles("Logs/*.log"));
@@ -90,13 +89,67 @@ public class LoggerFactoryExtensionsTests
logFileContents
);
}
[Fact]
public async Task AddSerilogFileLogging_LegacyConfig_WithLevelCustomization_InfoLogs_DoNotFillUpFile()
{
await AssertSmallFileAsync((tempDir, config) =>
{
config["GlobalSettings:LogDirectory"] = tempDir;
config["Logging:LogLevel:Microsoft.AspNetCore"] = "Warning";
});
}
[Fact]
public async Task AddSerilogFileLogging_NewConfig_WithLevelCustomization_InfoLogs_DoNotFillUpFile()
{
await AssertSmallFileAsync((tempDir, config) =>
{
config["Logging:PathFormat"] = Path.Combine(tempDir, "log.txt");
config["Logging:LogLevel:Microsoft.AspNetCore"] = "Warning";
});
}
private static async Task AssertSmallFileAsync(Action<string, Dictionary<string, string?>> configure)
{
using var tempDir = new TempDirectory();
var config = new Dictionary<string, string?>();
configure(tempDir.Directory, config);
var provider = GetServiceProvider(config, "Production");
var loggerFactory = provider.GetRequiredService<ILoggerFactory>();
var microsoftLogger = loggerFactory.CreateLogger("Microsoft.AspNetCore.Testing");
for (var i = 0; i < 100; i++)
{
microsoftLogger.LogInformation("Tons of useless information");
}
var otherLogger = loggerFactory.CreateLogger("Bitwarden");
for (var i = 0; i < 5; i++)
{
otherLogger.LogInformation("Mildly more useful information but not as frequent.");
}
await provider.DisposeAsync();
var logFiles = Directory.EnumerateFiles(tempDir.Directory, "*.txt", SearchOption.AllDirectories);
var logFile = Assert.Single(logFiles);
using var fr = File.OpenRead(logFile);
Assert.InRange(fr.Length, 0, 1024);
}
private static IEnumerable<ILoggerProvider> GetProviders(Dictionary<string, string?> initialData, string environment = "Production")
{
var provider = GetServiceProvider(initialData, environment);
return provider.GetServices<ILoggerProvider>();
}
private static IServiceProvider GetServiceProvider(Dictionary<string, string?> initialData, string environment)
private static ServiceProvider GetServiceProvider(Dictionary<string, string?> initialData, string environment)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(initialData)

View File

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

View File

@@ -8,254 +8,7 @@ namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories;
public class OrganizationRepositoryTests
{
[DatabaseTheory, 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.CreateAsync(new Organization
{
Name = $"Test Org {id}",
BillingEmail = user1.Email, // TODO: EF does not enforce this being NOT NULL
Plan = "Test", // TODO: EF does not enforce this being NOT NULL
PrivateKey = "privatekey",
});
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.CreateAsync(new OrganizationUser
{
OrganizationId = organization.Id,
UserId = user1.Id,
Status = OrganizationUserStatusType.Confirmed,
ResetPasswordKey = "resetpasswordkey1",
});
await organizationUserRepository.CreateAsync(new OrganizationUser
{
OrganizationId = organization.Id,
UserId = user2.Id,
Status = OrganizationUserStatusType.Confirmed,
ResetPasswordKey = "resetpasswordkey1",
});
await organizationUserRepository.CreateAsync(new OrganizationUser
{
OrganizationId = organization.Id,
UserId = user3.Id,
Status = OrganizationUserStatusType.Confirmed,
ResetPasswordKey = "resetpasswordkey1",
});
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);
}
[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);
}
[DatabaseTheory, DatabaseData]
[Theory, DatabaseData]
public async Task GetManyByIdsAsync_ExistingOrganizations_ReturnsOrganizations(IOrganizationRepository organizationRepository)
{
var email = "test@email.com";
@@ -287,7 +40,7 @@ public class OrganizationRepositoryTests
await organizationRepository.DeleteAsync(organization2);
}
[DatabaseTheory, DatabaseData]
[Theory, DatabaseData]
public async Task GetOccupiedSeatCountByOrganizationIdAsync_WithUsersAndSponsorships_ReturnsCorrectCounts(
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
@@ -356,7 +109,7 @@ public class OrganizationRepositoryTests
Assert.Equal(4, result.Total); // Total occupied seats
}
[DatabaseTheory, DatabaseData]
[Theory, DatabaseData]
public async Task GetOccupiedSeatCountByOrganizationIdAsync_WithNoUsersOrSponsorships_ReturnsZero(
IOrganizationRepository organizationRepository)
{
@@ -372,7 +125,7 @@ public class OrganizationRepositoryTests
Assert.Equal(0, result.Total);
}
[DatabaseTheory, DatabaseData]
[Theory, DatabaseData]
public async Task GetOccupiedSeatCountByOrganizationIdAsync_WithOnlyRevokedUsers_ReturnsZero(
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
@@ -399,7 +152,7 @@ public class OrganizationRepositoryTests
Assert.Equal(0, result.Total);
}
[DatabaseTheory, DatabaseData]
[Theory, DatabaseData]
public async Task GetOccupiedSeatCountByOrganizationIdAsync_WithOnlyExpiredSponsorships_ReturnsZero(
IOrganizationRepository organizationRepository,
IOrganizationSponsorshipRepository organizationSponsorshipRepository)
@@ -424,7 +177,7 @@ public class OrganizationRepositoryTests
Assert.Equal(0, result.Total);
}
[DatabaseTheory, DatabaseData]
[Theory, DatabaseData]
public async Task IncrementSeatCountAsync_IncrementsSeatCount(IOrganizationRepository organizationRepository)
{
var organization = await organizationRepository.CreateTestOrganizationAsync();
@@ -438,7 +191,7 @@ public class OrganizationRepositoryTests
Assert.Equal(8, result.Seats);
}
[DatabaseData, DatabaseTheory]
[DatabaseData, Theory]
public async Task IncrementSeatCountAsync_GivenOrganizationHasNotChangedSeatCountBefore_WhenUpdatingOrgSeats_ThenSubscriptionUpdateIsSaved(
IOrganizationRepository sutRepository)
{
@@ -462,7 +215,7 @@ public class OrganizationRepositoryTests
await sutRepository.DeleteAsync(organization);
}
[DatabaseData, DatabaseTheory]
[DatabaseData, Theory]
public async Task IncrementSeatCountAsync_GivenOrganizationHasChangedSeatCountBeforeAndRecordExists_WhenUpdatingOrgSeats_ThenSubscriptionUpdateIsSaved(
IOrganizationRepository sutRepository)
{
@@ -487,7 +240,7 @@ public class OrganizationRepositoryTests
await sutRepository.DeleteAsync(organization);
}
[DatabaseData, DatabaseTheory]
[DatabaseData, Theory]
public async Task GetOrganizationsForSubscriptionSyncAsync_GivenOrganizationHasChangedSeatCount_WhenGettingOrgsToUpdate_ThenReturnsOrgSubscriptionUpdate(
IOrganizationRepository sutRepository)
{
@@ -510,7 +263,7 @@ public class OrganizationRepositoryTests
await sutRepository.DeleteAsync(organization);
}
[DatabaseData, DatabaseTheory]
[DatabaseData, Theory]
public async Task UpdateSuccessfulOrganizationSyncStatusAsync_GivenOrganizationHasChangedSeatCount_WhenUpdatingStatus_ThenSuccessfullyUpdatesOrgSoItDoesntSync(
IOrganizationRepository sutRepository)
{

View File

@@ -0,0 +1,197 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Xunit;
namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.OrganizationUserRepository;
public class GetManyByOrganizationWithClaimedDomainsAsyncTests
{
[Theory, DatabaseData]
public async Task WithVerifiedDomain_WithOneMatchingEmailDomain_ReturnsSingle(
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 3",
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);
var orgUser1 = await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user1);
await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user2);
await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user3);
var result = await organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organization.Id);
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal(orgUser1.Id, result.Single().Id);
}
[Theory, DatabaseData]
public async Task WithNoVerifiedDomain_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();
// Create domain but do NOT verify it
var organizationDomain = new OrganizationDomain
{
OrganizationId = organization.Id,
DomainName = domainName,
Txt = "btw+12345",
};
organizationDomain.SetNextRunDate(12);
// Note: NOT calling SetVerifiedDate()
await organizationDomainRepository.CreateAsync(organizationDomain);
await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user);
var result = await organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organization.Id);
Assert.NotNull(result);
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 WithVerifiedDomain_ExcludesInvitedUsers(
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationDomainRepository organizationDomainRepository)
{
var id = Guid.NewGuid();
var domainName = $"{id}.example.com";
var invitedUser = await userRepository.CreateAsync(new User
{
Name = "Invited User",
Email = $"invited+{id}@{domainName}",
ApiKey = "TEST",
SecurityStamp = "stamp",
Kdf = KdfType.PBKDF2_SHA256,
KdfIterations = 1,
KdfMemory = 2,
KdfParallelism = 3
});
var confirmedUser = await userRepository.CreateAsync(new User
{
Name = "Confirmed User",
Email = $"confirmed+{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 UserId set (edge case - should be excluded even with UserId linked)
var invitedOrgUser = await organizationUserRepository.CreateAsync(new OrganizationUser
{
OrganizationId = organization.Id,
UserId = invitedUser.Id, // Edge case: invited user with UserId set
Email = invitedUser.Email,
Status = OrganizationUserStatusType.Invited,
Type = OrganizationUserType.User
});
// Create confirmed user linked by UserId only (no Email field set)
var confirmedOrgUser = await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, confirmedUser);
var result = await organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organization.Id);
Assert.NotNull(result);
var claimedUser = Assert.Single(result);
Assert.Equal(confirmedOrgUser.Id, claimedUser.Id);
}
}

View File

@@ -599,136 +599,6 @@ public class OrganizationUserRepositoryTests
Assert.Null(orgWithoutSsoDetails.SsoConfig);
}
[DatabaseTheory, DatabaseData]
public async Task GetManyByOrganizationWithClaimedDomainsAsync_WithVerifiedDomain_WithOneMatchingEmailDomain_ReturnsSingle(
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.CreateAsync(new Organization
{
Name = $"Test Org {id}",
BillingEmail = user1.Email, // TODO: EF does not enforce this being NOT NULL
Plan = "Test", // TODO: EF does not enforce this being NOT NULL
PrivateKey = "privatekey",
UsePolicies = false,
UseSso = false,
UseKeyConnector = false,
UseScim = false,
UseGroups = false,
UseDirectory = false,
UseEvents = false,
UseTotp = false,
Use2fa = false,
UseApi = false,
UseResetPassword = false,
UseSecretsManager = false,
SelfHost = false,
UsersGetPremium = false,
UseCustomPermissions = false,
Enabled = true,
UsePasswordManager = false,
LimitCollectionCreation = false,
LimitCollectionDeletion = false,
LimitItemDeletion = false,
AllowAdminAccessToAllCollectionItems = false,
UseRiskInsights = false,
UseAdminSponsoredFamilies = false,
UsePhishingBlocker = false,
UseDisableSmAdsForUsers = false,
});
var organizationDomain = new OrganizationDomain
{
OrganizationId = organization.Id,
DomainName = domainName,
Txt = "btw+12345",
};
organizationDomain.SetVerifiedDate();
organizationDomain.SetNextRunDate(12);
organizationDomain.SetJobRunCount();
await organizationDomainRepository.CreateAsync(organizationDomain);
var orgUser1 = await organizationUserRepository.CreateAsync(new OrganizationUser
{
Id = CoreHelpers.GenerateComb(),
OrganizationId = organization.Id,
UserId = user1.Id,
Status = OrganizationUserStatusType.Confirmed,
Type = OrganizationUserType.Owner,
ResetPasswordKey = "resetpasswordkey1",
AccessSecretsManager = false
});
await organizationUserRepository.CreateAsync(new OrganizationUser
{
Id = CoreHelpers.GenerateComb(),
OrganizationId = organization.Id,
UserId = user2.Id,
Status = OrganizationUserStatusType.Confirmed,
Type = OrganizationUserType.User,
ResetPasswordKey = "resetpasswordkey1",
AccessSecretsManager = false
});
await organizationUserRepository.CreateAsync(new OrganizationUser
{
Id = CoreHelpers.GenerateComb(),
OrganizationId = organization.Id,
UserId = user3.Id,
Status = OrganizationUserStatusType.Confirmed,
Type = OrganizationUserType.User,
ResetPasswordKey = "resetpasswordkey1",
AccessSecretsManager = false
});
var responseModel = await organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organization.Id);
Assert.NotNull(responseModel);
Assert.Single(responseModel);
Assert.Equal(orgUser1.Id, responseModel.Single().Id);
}
[DatabaseTheory, DatabaseData]
public async Task CreateManyAsync_NoId_Works(IOrganizationRepository organizationRepository,
IUserRepository userRepository,
@@ -1237,70 +1107,6 @@ public class OrganizationUserRepositoryTests
Assert.DoesNotContain(user1Result.Collections, c => c.Id == defaultUserCollection.Id);
}
[DatabaseTheory, DatabaseData]
public async Task GetManyByOrganizationWithClaimedDomainsAsync_WithNoVerifiedDomain_ReturnsEmpty(
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationDomainRepository organizationDomainRepository)
{
var id = Guid.NewGuid();
var domainName = $"{id}.example.com";
var requestTime = DateTime.UtcNow;
var user1 = await userRepository.CreateAsync(new User
{
Id = CoreHelpers.GenerateComb(),
Name = "Test User 1",
Email = $"test+{id}@{domainName}",
ApiKey = "TEST",
SecurityStamp = "stamp",
CreationDate = requestTime,
RevisionDate = requestTime,
AccountRevisionDate = requestTime
});
var organization = await organizationRepository.CreateAsync(new Organization
{
Id = CoreHelpers.GenerateComb(),
Name = $"Test Org {id}",
BillingEmail = user1.Email,
Plan = "Test",
Enabled = true,
CreationDate = requestTime,
RevisionDate = requestTime
});
// Create domain but do NOT verify it
var organizationDomain = new OrganizationDomain
{
Id = CoreHelpers.GenerateComb(),
OrganizationId = organization.Id,
DomainName = domainName,
Txt = "btw+12345",
CreationDate = requestTime
};
organizationDomain.SetNextRunDate(12);
// Note: NOT calling SetVerifiedDate()
await organizationDomainRepository.CreateAsync(organizationDomain);
await organizationUserRepository.CreateAsync(new OrganizationUser
{
Id = CoreHelpers.GenerateComb(),
OrganizationId = organization.Id,
UserId = user1.Id,
Status = OrganizationUserStatusType.Confirmed,
Type = OrganizationUserType.Owner,
CreationDate = requestTime,
RevisionDate = requestTime
});
var responseModel = await organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organization.Id);
Assert.NotNull(responseModel);
Assert.Empty(responseModel);
}
[DatabaseTheory, DatabaseData]
public async Task DeleteAsync_WithNullEmail_DoesNotSetDefaultUserCollectionEmail(IUserRepository userRepository,
ICollectionRepository collectionRepository,

View File

@@ -0,0 +1,24 @@
CREATE OR ALTER PROCEDURE [dbo].[Organization_ReadByClaimedUserEmailDomain]
@UserId UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON;
WITH CTE_User AS (
SELECT
U.[Id],
SUBSTRING(U.Email, CHARINDEX('@', U.Email) + 1, LEN(U.Email)) AS EmailDomain
FROM dbo.[UserView] U
WHERE U.[Id] = @UserId
)
SELECT O.*
FROM CTE_User CU
INNER JOIN dbo.[OrganizationUserView] OU ON CU.[Id] = OU.[UserId]
INNER JOIN dbo.[OrganizationView] O ON OU.[OrganizationId] = O.[Id]
INNER JOIN dbo.[OrganizationDomainView] OD ON OU.[OrganizationId] = OD.[OrganizationId]
WHERE OD.[VerifiedDate] IS NOT NULL
AND CU.EmailDomain = OD.[DomainName]
AND O.[Enabled] = 1
AND OU.[Status] != 0 -- Exclude invited users
END
GO

View File

@@ -0,0 +1,29 @@
CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_ReadByOrganizationIdWithClaimedDomains_V2]
@OrganizationId UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON;
WITH OrgUsers AS (
SELECT *
FROM [dbo].[OrganizationUserView]
WHERE [OrganizationId] = @OrganizationId
AND [Status] != 0 -- Exclude invited users
),
UserDomains AS (
SELECT U.[Id], U.[EmailDomain]
FROM [dbo].[UserEmailDomainView] U
WHERE EXISTS (
SELECT 1
FROM [dbo].[OrganizationDomainView] OD
WHERE OD.[OrganizationId] = @OrganizationId
AND OD.[VerifiedDate] IS NOT NULL
AND OD.[DomainName] = U.[EmailDomain]
)
)
SELECT OU.*
FROM OrgUsers OU
JOIN UserDomains UD ON OU.[UserId] = UD.[Id]
OPTION (RECOMPILE);
END
GO