mirror of
https://github.com/bitwarden/server
synced 2026-01-10 12:33:49 +00:00
Merge branch 'main' into ac/pm-21742/update-confirmed-to-org-email-templates
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
|
||||
@@ -25,6 +25,12 @@
|
||||
"connectionString": "UseDevelopmentStorage=true"
|
||||
},
|
||||
"developmentDirectory": "../../../dev",
|
||||
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
|
||||
"pricingUri": "https://billingpricing.qa.bitwarden.pw",
|
||||
"mail": {
|
||||
"smtp": {
|
||||
"host": "localhost",
|
||||
"port": 10250
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,11 @@
|
||||
"mail": {
|
||||
"sendGridApiKey": "SECRET",
|
||||
"amazonConfigSetName": "Email",
|
||||
"replyToEmail": "no-reply@bitwarden.com"
|
||||
"replyToEmail": "no-reply@bitwarden.com",
|
||||
"smtp": {
|
||||
"host": "localhost",
|
||||
"port": 10250
|
||||
}
|
||||
},
|
||||
"identityServer": {
|
||||
"certificateThumbprint": "SECRET"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
|
||||
@@ -41,6 +41,8 @@ using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using V1_RevokeOrganizationUserCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1.IRevokeOrganizationUserCommand;
|
||||
using V2_RevokeOrganizationUserCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Controllers;
|
||||
|
||||
@@ -71,11 +73,13 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IPricingClient _pricingClient;
|
||||
private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand;
|
||||
private readonly IBulkResendOrganizationInvitesCommand _bulkResendOrganizationInvitesCommand;
|
||||
private readonly IAutomaticallyConfirmOrganizationUserCommand _automaticallyConfirmOrganizationUserCommand;
|
||||
private readonly V2_RevokeOrganizationUserCommand.IRevokeOrganizationUserCommand _revokeOrganizationUserCommandVNext;
|
||||
private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand;
|
||||
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
|
||||
private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand;
|
||||
private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommand;
|
||||
private readonly V1_RevokeOrganizationUserCommand _revokeOrganizationUserCommand;
|
||||
private readonly IAdminRecoverAccountCommand _adminRecoverAccountCommand;
|
||||
|
||||
public OrganizationUsersController(IOrganizationRepository organizationRepository,
|
||||
@@ -103,10 +107,12 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
||||
IConfirmOrganizationUserCommand confirmOrganizationUserCommand,
|
||||
IRestoreOrganizationUserCommand restoreOrganizationUserCommand,
|
||||
IInitPendingOrganizationCommand initPendingOrganizationCommand,
|
||||
IRevokeOrganizationUserCommand revokeOrganizationUserCommand,
|
||||
V1_RevokeOrganizationUserCommand revokeOrganizationUserCommand,
|
||||
IResendOrganizationInviteCommand resendOrganizationInviteCommand,
|
||||
IBulkResendOrganizationInvitesCommand bulkResendOrganizationInvitesCommand,
|
||||
IAdminRecoverAccountCommand adminRecoverAccountCommand,
|
||||
IAutomaticallyConfirmOrganizationUserCommand automaticallyConfirmOrganizationUserCommand)
|
||||
IAutomaticallyConfirmOrganizationUserCommand automaticallyConfirmOrganizationUserCommand,
|
||||
V2_RevokeOrganizationUserCommand.IRevokeOrganizationUserCommand revokeOrganizationUserCommandVNext)
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
@@ -131,7 +137,9 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
||||
_featureService = featureService;
|
||||
_pricingClient = pricingClient;
|
||||
_resendOrganizationInviteCommand = resendOrganizationInviteCommand;
|
||||
_bulkResendOrganizationInvitesCommand = bulkResendOrganizationInvitesCommand;
|
||||
_automaticallyConfirmOrganizationUserCommand = automaticallyConfirmOrganizationUserCommand;
|
||||
_revokeOrganizationUserCommandVNext = revokeOrganizationUserCommandVNext;
|
||||
_confirmOrganizationUserCommand = confirmOrganizationUserCommand;
|
||||
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
|
||||
_initPendingOrganizationCommand = initPendingOrganizationCommand;
|
||||
@@ -273,7 +281,17 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
||||
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkReinvite(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User);
|
||||
var result = await _organizationService.ResendInvitesAsync(orgId, userId.Value, model.Ids);
|
||||
|
||||
IEnumerable<Tuple<Core.Entities.OrganizationUser, string>> result;
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.IncreaseBulkReinviteLimitForCloud))
|
||||
{
|
||||
result = await _bulkResendOrganizationInvitesCommand.BulkResendInvitesAsync(orgId, userId.Value, model.Ids);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await _organizationService.ResendInvitesAsync(orgId, userId.Value, model.Ids);
|
||||
}
|
||||
|
||||
return new ListResponseModel<OrganizationUserBulkResponseModel>(
|
||||
result.Select(t => new OrganizationUserBulkResponseModel(t.Item1.Id, t.Item2)));
|
||||
}
|
||||
@@ -629,7 +647,29 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
||||
[Authorize<ManageUsersRequirement>]
|
||||
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkRevokeAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
|
||||
{
|
||||
return await RestoreOrRevokeUsersAsync(orgId, model, _revokeOrganizationUserCommand.RevokeUsersAsync);
|
||||
if (!_featureService.IsEnabled(FeatureFlagKeys.BulkRevokeUsersV2))
|
||||
{
|
||||
return await RestoreOrRevokeUsersAsync(orgId, model, _revokeOrganizationUserCommand.RevokeUsersAsync);
|
||||
}
|
||||
|
||||
var currentUserId = _userService.GetProperUserId(User);
|
||||
if (currentUserId == null)
|
||||
{
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
|
||||
var results = await _revokeOrganizationUserCommandVNext.RevokeUsersAsync(
|
||||
new V2_RevokeOrganizationUserCommand.RevokeOrganizationUsersRequest(
|
||||
orgId,
|
||||
model.Ids.ToArray(),
|
||||
new StandardUser(currentUserId.Value, await _currentContext.OrganizationOwner(orgId))));
|
||||
|
||||
return new ListResponseModel<OrganizationUserBulkResponseModel>(results
|
||||
.Select(result => new OrganizationUserBulkResponseModel(result.Id,
|
||||
result.Result.Match(
|
||||
error => error.Message,
|
||||
_ => string.Empty
|
||||
))));
|
||||
}
|
||||
|
||||
[HttpPatch("revoke")]
|
||||
|
||||
@@ -119,7 +119,7 @@ public class OrganizationUserResetPasswordEnrollmentRequestModel
|
||||
|
||||
public class OrganizationUserBulkRequestModel
|
||||
{
|
||||
[Required]
|
||||
[Required, MinLength(1)]
|
||||
public IEnumerable<Guid> Ids { get; set; }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Licenses;
|
||||
using Bit.Core.Billing.Licenses.Extensions;
|
||||
using Bit.Core.Billing.Organizations.Models;
|
||||
using Bit.Core.Models.Api;
|
||||
using Bit.Core.Models.Business;
|
||||
@@ -177,6 +180,30 @@ public class OrganizationSubscriptionResponseModel : OrganizationResponseModel
|
||||
}
|
||||
}
|
||||
|
||||
public OrganizationSubscriptionResponseModel(Organization organization, OrganizationLicense license, ClaimsPrincipal claimsPrincipal) :
|
||||
this(organization, (Plan)null)
|
||||
{
|
||||
if (license != null)
|
||||
{
|
||||
// CRITICAL: When a license has a Token (JWT), ALWAYS use the expiration from the token claim
|
||||
// The token's expiration is cryptographically secured and cannot be tampered with
|
||||
// The file's Expires property can be manually edited and should NOT be trusted for display
|
||||
if (claimsPrincipal != null)
|
||||
{
|
||||
Expiration = claimsPrincipal.GetValue<DateTime>(OrganizationLicenseConstants.Expires);
|
||||
ExpirationWithoutGracePeriod = claimsPrincipal.GetValue<DateTime?>(OrganizationLicenseConstants.ExpirationWithoutGracePeriod);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No token - use the license file expiration (for older licenses without tokens)
|
||||
Expiration = license.Expires;
|
||||
ExpirationWithoutGracePeriod = license.ExpirationWithoutGracePeriod ?? (license.Trial
|
||||
? license.Expires
|
||||
: license.Expires?.AddDays(-Constants.OrganizationSelfHostSubscriptionGracePeriodDays));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string StorageName { get; set; }
|
||||
public double? StorageGb { get; set; }
|
||||
public BillingCustomerDiscount CustomerDiscount { get; set; }
|
||||
|
||||
@@ -26,7 +26,8 @@ public class AccountsController(
|
||||
IUserService userService,
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||
IUserAccountKeysQuery userAccountKeysQuery,
|
||||
IFeatureService featureService) : Controller
|
||||
IFeatureService featureService,
|
||||
ILicensingService licensingService) : Controller
|
||||
{
|
||||
[HttpPost("premium")]
|
||||
public async Task<PaymentResponseModel> PostPremiumAsync(
|
||||
@@ -97,12 +98,14 @@ public class AccountsController(
|
||||
var includeMilestone2Discount = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2);
|
||||
var subscriptionInfo = await paymentService.GetSubscriptionAsync(user);
|
||||
var license = await userService.GenerateLicenseAsync(user, subscriptionInfo);
|
||||
return new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount);
|
||||
var claimsPrincipal = licensingService.GetClaimsPrincipalFromLicense(license);
|
||||
return new SubscriptionResponseModel(user, subscriptionInfo, license, claimsPrincipal, includeMilestone2Discount);
|
||||
}
|
||||
else
|
||||
{
|
||||
var license = await userService.GenerateLicenseAsync(user);
|
||||
return new SubscriptionResponseModel(user, license);
|
||||
var claimsPrincipal = licensingService.GetClaimsPrincipalFromLicense(license);
|
||||
return new SubscriptionResponseModel(user, null, license, claimsPrincipal);
|
||||
}
|
||||
}
|
||||
else
|
||||
|
||||
@@ -67,7 +67,8 @@ public class OrganizationsController(
|
||||
if (globalSettings.SelfHosted)
|
||||
{
|
||||
var orgLicense = await licensingService.ReadOrganizationLicenseAsync(organization);
|
||||
return new OrganizationSubscriptionResponseModel(organization, orgLicense);
|
||||
var claimsPrincipal = licensingService.GetClaimsPrincipalFromLicense(orgLicense);
|
||||
return new OrganizationSubscriptionResponseModel(organization, orgLicense, claimsPrincipal);
|
||||
}
|
||||
|
||||
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using Bit.Core.Billing.Constants;
|
||||
using System.Security.Claims;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Licenses;
|
||||
using Bit.Core.Billing.Licenses.Extensions;
|
||||
using Bit.Core.Billing.Models.Business;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Models.Api;
|
||||
@@ -37,6 +40,46 @@ public class SubscriptionResponseModel : ResponseModel
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <param name="user">The user entity containing storage and premium subscription information</param>
|
||||
/// <param name="subscription">Subscription information retrieved from the payment provider (Stripe/Braintree)</param>
|
||||
/// <param name="license">The user's license containing expiration and feature entitlements</param>
|
||||
/// <param name="claimsPrincipal">The claims principal containing cryptographically secure token claims</param>
|
||||
/// <param name="includeMilestone2Discount">
|
||||
/// Whether to include discount information in the response.
|
||||
/// Set to true when the PM23341_Milestone_2 feature flag is enabled AND
|
||||
/// you want to expose Milestone 2 discount information to the client.
|
||||
/// The discount will only be included if it matches the specific Milestone 2 coupon ID.
|
||||
/// </param>
|
||||
public SubscriptionResponseModel(User user, SubscriptionInfo? subscription, UserLicense license, ClaimsPrincipal? claimsPrincipal, bool includeMilestone2Discount = false)
|
||||
: base("subscription")
|
||||
{
|
||||
Subscription = subscription?.Subscription != null ? new BillingSubscription(subscription.Subscription) : null;
|
||||
UpcomingInvoice = subscription?.UpcomingInvoice != null ?
|
||||
new BillingSubscriptionUpcomingInvoice(subscription.UpcomingInvoice) : null;
|
||||
StorageName = user.Storage.HasValue ? CoreHelpers.ReadableBytesSize(user.Storage.Value) : null;
|
||||
StorageGb = user.Storage.HasValue ? Math.Round(user.Storage.Value / 1073741824D, 2) : 0; // 1 GB
|
||||
MaxStorageGb = user.MaxStorageGb;
|
||||
License = license;
|
||||
|
||||
// CRITICAL: When a license has a Token (JWT), ALWAYS use the expiration from the token claim
|
||||
// The token's expiration is cryptographically secured and cannot be tampered with
|
||||
// The file's Expires property can be manually edited and should NOT be trusted for display
|
||||
if (claimsPrincipal != null)
|
||||
{
|
||||
Expiration = claimsPrincipal.GetValue<DateTime?>(UserLicenseConstants.Expires);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No token - use the license file expiration (for older licenses without tokens)
|
||||
Expiration = License.Expires;
|
||||
}
|
||||
|
||||
// Only display the Milestone 2 subscription discount on the subscription page.
|
||||
CustomerDiscount = ShouldIncludeMilestone2Discount(includeMilestone2Discount, subscription?.CustomerDiscount)
|
||||
? new BillingCustomerDiscount(subscription!.CustomerDiscount!)
|
||||
: null;
|
||||
}
|
||||
|
||||
public SubscriptionResponseModel(User user, UserLicense? license = null)
|
||||
: base("subscription")
|
||||
{
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Billing.Services;
|
||||
using Bit.Billing.Services;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Repositories;
|
||||
using Quartz;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Billing.Jobs;
|
||||
|
||||
using static StripeConstants;
|
||||
|
||||
public class SubscriptionCancellationJob(
|
||||
IStripeFacade stripeFacade,
|
||||
IOrganizationRepository organizationRepository)
|
||||
IOrganizationRepository organizationRepository,
|
||||
ILogger<SubscriptionCancellationJob> logger)
|
||||
: IJob
|
||||
{
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
@@ -21,20 +22,31 @@ public class SubscriptionCancellationJob(
|
||||
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||
if (organization == null || organization.Enabled)
|
||||
{
|
||||
logger.LogWarning("{Job} skipped for subscription ({SubscriptionID}) because organization is either null or enabled", nameof(SubscriptionCancellationJob), subscriptionId);
|
||||
// Organization was deleted or re-enabled by CS, skip cancellation
|
||||
return;
|
||||
}
|
||||
|
||||
var subscription = await stripeFacade.GetSubscription(subscriptionId);
|
||||
if (subscription?.Status != "unpaid" ||
|
||||
subscription.LatestInvoice?.BillingReason is not ("subscription_cycle" or "subscription_create"))
|
||||
var subscription = await stripeFacade.GetSubscription(subscriptionId, new SubscriptionGetOptions
|
||||
{
|
||||
Expand = ["latest_invoice"]
|
||||
});
|
||||
|
||||
if (subscription is not
|
||||
{
|
||||
Status: SubscriptionStatus.Unpaid,
|
||||
LatestInvoice: { BillingReason: BillingReasons.SubscriptionCreate or BillingReasons.SubscriptionCycle }
|
||||
})
|
||||
{
|
||||
logger.LogWarning("{Job} skipped for subscription ({SubscriptionID}) because subscription is not unpaid or does not have a cancellable billing reason", nameof(SubscriptionCancellationJob), subscriptionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel the subscription
|
||||
await stripeFacade.CancelSubscription(subscriptionId, new SubscriptionCancelOptions());
|
||||
|
||||
logger.LogInformation("{Job} cancelled subscription ({SubscriptionID})", nameof(SubscriptionCancellationJob), subscriptionId);
|
||||
|
||||
// Void any open invoices
|
||||
var options = new InvoiceListOptions
|
||||
{
|
||||
@@ -46,6 +58,7 @@ public class SubscriptionCancellationJob(
|
||||
foreach (var invoice in invoices)
|
||||
{
|
||||
await stripeFacade.VoidInvoice(invoice.Id);
|
||||
logger.LogInformation("{Job} voided invoice ({InvoiceID}) for subscription ({SubscriptionID})", nameof(SubscriptionCancellationJob), invoice.Id, subscriptionId);
|
||||
}
|
||||
|
||||
while (invoices.HasMore)
|
||||
@@ -55,6 +68,7 @@ public class SubscriptionCancellationJob(
|
||||
foreach (var invoice in invoices)
|
||||
{
|
||||
await stripeFacade.VoidInvoice(invoice.Id);
|
||||
logger.LogInformation("{Job} voided invoice ({InvoiceID}) for subscription ({SubscriptionID})", nameof(SubscriptionCancellationJob), invoice.Id, subscriptionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.AdminConsole.Entities;
|
||||
|
||||
public class OrganizationIntegration : ITableObject<Guid>
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.AdminConsole.Entities;
|
||||
|
||||
public class OrganizationIntegrationConfiguration : ITableObject<Guid>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||
using Bit.Core.AdminConsole.Utilities.DebuggingInstruments;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||
|
||||
public class BulkResendOrganizationInvitesCommand : IBulkResendOrganizationInvitesCommand
|
||||
{
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand;
|
||||
private readonly ILogger<BulkResendOrganizationInvitesCommand> _logger;
|
||||
|
||||
public BulkResendOrganizationInvitesCommand(
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
ISendOrganizationInvitesCommand sendOrganizationInvitesCommand,
|
||||
ILogger<BulkResendOrganizationInvitesCommand> logger)
|
||||
{
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_organizationRepository = organizationRepository;
|
||||
_sendOrganizationInvitesCommand = sendOrganizationInvitesCommand;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Tuple<OrganizationUser, string>>> BulkResendInvitesAsync(
|
||||
Guid organizationId,
|
||||
Guid? invitingUserId,
|
||||
IEnumerable<Guid> organizationUsersId)
|
||||
{
|
||||
var orgUsers = await _organizationUserRepository.GetManyAsync(organizationUsersId);
|
||||
_logger.LogUserInviteStateDiagnostics(orgUsers);
|
||||
|
||||
var org = await _organizationRepository.GetByIdAsync(organizationId);
|
||||
if (org == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var validUsers = new List<OrganizationUser>();
|
||||
var result = new List<Tuple<OrganizationUser, string>>();
|
||||
|
||||
foreach (var orgUser in orgUsers)
|
||||
{
|
||||
if (orgUser.Status != OrganizationUserStatusType.Invited || orgUser.OrganizationId != organizationId)
|
||||
{
|
||||
result.Add(Tuple.Create(orgUser, "User invalid."));
|
||||
}
|
||||
else
|
||||
{
|
||||
validUsers.Add(orgUser);
|
||||
}
|
||||
}
|
||||
|
||||
if (validUsers.Any())
|
||||
{
|
||||
await _sendOrganizationInvitesCommand.SendInvitesAsync(
|
||||
new SendInvitesRequest(validUsers, org));
|
||||
|
||||
result.AddRange(validUsers.Select(u => Tuple.Create(u, "")));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Bit.Core.Entities;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||
|
||||
public interface IBulkResendOrganizationInvitesCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Resend invites to multiple organization users in bulk.
|
||||
/// </summary>
|
||||
/// <param name="organizationId">The ID of the organization.</param>
|
||||
/// <param name="invitingUserId">The ID of the user who is resending the invites.</param>
|
||||
/// <param name="organizationUsersId">The IDs of the organization users to resend invites to.</param>
|
||||
/// <returns>A tuple containing the OrganizationUser and an error message (empty string if successful)</returns>
|
||||
Task<IEnumerable<Tuple<OrganizationUser, string>>> BulkResendInvitesAsync(
|
||||
Guid organizationId,
|
||||
Guid? invitingUserId,
|
||||
IEnumerable<Guid> organizationUsersId);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1;
|
||||
|
||||
public interface IRevokeOrganizationUserCommand
|
||||
{
|
||||
@@ -7,7 +7,7 @@ using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1;
|
||||
|
||||
public class RevokeOrganizationUserCommand(
|
||||
IEventService eventService,
|
||||
@@ -0,0 +1,8 @@
|
||||
using Bit.Core.AdminConsole.Utilities.v2;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;
|
||||
|
||||
public record UserAlreadyRevoked() : BadRequestError("Already revoked.");
|
||||
public record CannotRevokeYourself() : BadRequestError("You cannot revoke yourself.");
|
||||
public record OnlyOwnersCanRevokeOwners() : BadRequestError("Only owners can revoke other owners.");
|
||||
public record MustHaveConfirmedOwner() : BadRequestError("Organization must have at least one confirmed owner.");
|
||||
@@ -0,0 +1,8 @@
|
||||
using Bit.Core.AdminConsole.Utilities.v2.Results;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;
|
||||
|
||||
public interface IRevokeOrganizationUserCommand
|
||||
{
|
||||
Task<IEnumerable<BulkCommandResult>> RevokeUsersAsync(RevokeOrganizationUsersRequest request);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using Bit.Core.AdminConsole.Utilities.v2.Validation;
|
||||
using Bit.Core.Entities;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;
|
||||
|
||||
public interface IRevokeOrganizationUserValidator
|
||||
{
|
||||
Task<ICollection<ValidationResult<OrganizationUser>>> ValidateAsync(RevokeOrganizationUsersValidationRequest request);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using Bit.Core.AdminConsole.Models.Data;
|
||||
using Bit.Core.AdminConsole.Utilities.v2.Results;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OneOf.Types;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;
|
||||
|
||||
public class RevokeOrganizationUserCommand(
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IEventService eventService,
|
||||
IPushNotificationService pushNotificationService,
|
||||
IRevokeOrganizationUserValidator validator,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<RevokeOrganizationUserCommand> logger)
|
||||
: IRevokeOrganizationUserCommand
|
||||
{
|
||||
public async Task<IEnumerable<BulkCommandResult>> RevokeUsersAsync(RevokeOrganizationUsersRequest request)
|
||||
{
|
||||
var validationRequest = await CreateValidationRequestsAsync(request);
|
||||
|
||||
var results = await validator.ValidateAsync(validationRequest);
|
||||
|
||||
var validUsers = results.Where(r => r.IsValid).Select(r => r.Request).ToList();
|
||||
|
||||
await RevokeValidUsersAsync(validUsers);
|
||||
|
||||
await Task.WhenAll(
|
||||
LogRevokedOrganizationUsersAsync(validUsers, request.PerformedBy),
|
||||
SendPushNotificationsAsync(validUsers)
|
||||
);
|
||||
|
||||
return results.Select(r => r.Match(
|
||||
error => new BulkCommandResult(r.Request.Id, error),
|
||||
_ => new BulkCommandResult(r.Request.Id, new None())
|
||||
));
|
||||
}
|
||||
|
||||
private async Task<RevokeOrganizationUsersValidationRequest> CreateValidationRequestsAsync(
|
||||
RevokeOrganizationUsersRequest request)
|
||||
{
|
||||
var organizationUserToRevoke = await organizationUserRepository
|
||||
.GetManyAsync(request.OrganizationUserIdsToRevoke);
|
||||
|
||||
return new RevokeOrganizationUsersValidationRequest(
|
||||
request.OrganizationId,
|
||||
request.OrganizationUserIdsToRevoke,
|
||||
request.PerformedBy,
|
||||
organizationUserToRevoke);
|
||||
}
|
||||
|
||||
private async Task RevokeValidUsersAsync(ICollection<OrganizationUser> validUsers)
|
||||
{
|
||||
if (validUsers.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await organizationUserRepository.RevokeManyByIdAsync(validUsers.Select(u => u.Id));
|
||||
}
|
||||
|
||||
private async Task LogRevokedOrganizationUsersAsync(
|
||||
ICollection<OrganizationUser> revokedUsers,
|
||||
IActingUser actingUser)
|
||||
{
|
||||
if (revokedUsers.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var eventDate = timeProvider.GetUtcNow().UtcDateTime;
|
||||
|
||||
if (actingUser is SystemUser { SystemUserType: not null })
|
||||
{
|
||||
var revokeEventsWithSystem = revokedUsers
|
||||
.Select(user => (user, EventType.OrganizationUser_Revoked, actingUser.SystemUserType!.Value,
|
||||
(DateTime?)eventDate))
|
||||
.ToList();
|
||||
await eventService.LogOrganizationUserEventsAsync(revokeEventsWithSystem);
|
||||
}
|
||||
else
|
||||
{
|
||||
var revokeEvents = revokedUsers
|
||||
.Select(user => (user, EventType.OrganizationUser_Revoked, (DateTime?)eventDate))
|
||||
.ToList();
|
||||
await eventService.LogOrganizationUserEventsAsync(revokeEvents);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendPushNotificationsAsync(ICollection<OrganizationUser> revokedUsers)
|
||||
{
|
||||
var userIdsToNotify = revokedUsers
|
||||
.Where(user => user.UserId.HasValue)
|
||||
.Select(user => user.UserId!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
foreach (var userId in userIdsToNotify)
|
||||
{
|
||||
try
|
||||
{
|
||||
await pushNotificationService.PushSyncOrgKeysAsync(userId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to send push notification for user {UserId}.", userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using Bit.Core.AdminConsole.Models.Data;
|
||||
using Bit.Core.Entities;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;
|
||||
|
||||
public record RevokeOrganizationUsersRequest(
|
||||
Guid OrganizationId,
|
||||
ICollection<Guid> OrganizationUserIdsToRevoke,
|
||||
IActingUser PerformedBy
|
||||
);
|
||||
|
||||
public record RevokeOrganizationUsersValidationRequest(
|
||||
Guid OrganizationId,
|
||||
ICollection<Guid> OrganizationUserIdsToRevoke,
|
||||
IActingUser PerformedBy,
|
||||
ICollection<OrganizationUser> OrganizationUsersToRevoke
|
||||
) : RevokeOrganizationUsersRequest(OrganizationId, OrganizationUserIdsToRevoke, PerformedBy);
|
||||
@@ -0,0 +1,39 @@
|
||||
using Bit.Core.AdminConsole.Models.Data;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Utilities.v2.Validation;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;
|
||||
|
||||
public class RevokeOrganizationUsersValidator(IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery)
|
||||
: IRevokeOrganizationUserValidator
|
||||
{
|
||||
public async Task<ICollection<ValidationResult<OrganizationUser>>> ValidateAsync(
|
||||
RevokeOrganizationUsersValidationRequest request)
|
||||
{
|
||||
var hasRemainingOwner = await hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(request.OrganizationId,
|
||||
request.OrganizationUsersToRevoke.Select(x => x.Id) // users excluded because they are going to be revoked
|
||||
);
|
||||
|
||||
return request.OrganizationUsersToRevoke.Select(x =>
|
||||
{
|
||||
return x switch
|
||||
{
|
||||
_ when request.PerformedBy is not SystemUser
|
||||
&& x.UserId is not null
|
||||
&& x.UserId == request.PerformedBy.UserId =>
|
||||
Invalid(x, new CannotRevokeYourself()),
|
||||
{ Status: OrganizationUserStatusType.Revoked } =>
|
||||
Invalid(x, new UserAlreadyRevoked()),
|
||||
{ Type: OrganizationUserType.Owner } when !hasRemainingOwner =>
|
||||
Invalid(x, new MustHaveConfirmedOwner()),
|
||||
{ Type: OrganizationUserType.Owner } when !request.PerformedBy.IsOrganizationOwnerOrProvider =>
|
||||
Invalid(x, new OnlyOwnersCanRevokeOwners()),
|
||||
|
||||
_ => Valid(x)
|
||||
};
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
using Bit.Core.Repositories;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
|
||||
@@ -17,26 +18,13 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
|
||||
/// <li>All organization users are compliant with the Single organization policy</li>
|
||||
/// <li>No provider users exist</li>
|
||||
/// </ul>
|
||||
///
|
||||
/// This class also performs side effects when the policy is being enabled or disabled. They are:
|
||||
/// <ul>
|
||||
/// <li>Sets the UseAutomaticUserConfirmation organization feature to match the policy update</li>
|
||||
/// </ul>
|
||||
/// </summary>
|
||||
public class AutomaticUserConfirmationPolicyEventHandler(
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IProviderUserRepository providerUserRepository,
|
||||
IPolicyRepository policyRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
TimeProvider timeProvider)
|
||||
: IPolicyValidator, IPolicyValidationEvent, IOnPolicyPreUpdateEvent, IEnforceDependentPoliciesEvent
|
||||
IProviderUserRepository providerUserRepository)
|
||||
: IPolicyValidator, IPolicyValidationEvent, IEnforceDependentPoliciesEvent
|
||||
{
|
||||
public PolicyType Type => PolicyType.AutomaticUserConfirmation;
|
||||
public async Task ExecutePreUpsertSideEffectAsync(SavePolicyModel policyRequest, Policy? currentPolicy) =>
|
||||
await OnSaveSideEffectsAsync(policyRequest.PolicyUpdate, currentPolicy);
|
||||
|
||||
private const string _singleOrgPolicyNotEnabledErrorMessage =
|
||||
"The Single organization policy must be enabled before enabling the Automatically confirm invited users policy.";
|
||||
|
||||
private const string _usersNotCompliantWithSingleOrgErrorMessage =
|
||||
"All organization users must be compliant with the Single organization policy before enabling the Automatically confirm invited users policy. Please remove users who are members of multiple organizations.";
|
||||
@@ -61,27 +49,20 @@ public class AutomaticUserConfirmationPolicyEventHandler(
|
||||
public async Task<string> ValidateAsync(SavePolicyModel savePolicyModel, Policy? currentPolicy) =>
|
||||
await ValidateAsync(savePolicyModel.PolicyUpdate, currentPolicy);
|
||||
|
||||
public async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy)
|
||||
{
|
||||
var organization = await organizationRepository.GetByIdAsync(policyUpdate.OrganizationId);
|
||||
|
||||
if (organization is not null)
|
||||
{
|
||||
organization.UseAutomaticUserConfirmation = policyUpdate.Enabled;
|
||||
organization.RevisionDate = timeProvider.GetUtcNow().UtcDateTime;
|
||||
await organizationRepository.UpsertAsync(organization);
|
||||
}
|
||||
}
|
||||
public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
private async Task<string> ValidateEnablingPolicyAsync(Guid organizationId)
|
||||
{
|
||||
var singleOrgValidationError = await ValidateSingleOrgPolicyComplianceAsync(organizationId);
|
||||
var organizationUsers = await organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);
|
||||
|
||||
var singleOrgValidationError = await ValidateUserComplianceWithSingleOrgAsync(organizationId, organizationUsers);
|
||||
if (!string.IsNullOrWhiteSpace(singleOrgValidationError))
|
||||
{
|
||||
return singleOrgValidationError;
|
||||
}
|
||||
|
||||
var providerValidationError = await ValidateNoProviderUsersAsync(organizationId);
|
||||
var providerValidationError = await ValidateNoProviderUsersAsync(organizationUsers);
|
||||
if (!string.IsNullOrWhiteSpace(providerValidationError))
|
||||
{
|
||||
return providerValidationError;
|
||||
@@ -90,42 +71,24 @@ public class AutomaticUserConfirmationPolicyEventHandler(
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private async Task<string> ValidateSingleOrgPolicyComplianceAsync(Guid organizationId)
|
||||
private async Task<string> ValidateUserComplianceWithSingleOrgAsync(Guid organizationId,
|
||||
ICollection<OrganizationUserUserDetails> organizationUsers)
|
||||
{
|
||||
var singleOrgPolicy = await policyRepository.GetByOrganizationIdTypeAsync(organizationId, PolicyType.SingleOrg);
|
||||
if (singleOrgPolicy is not { Enabled: true })
|
||||
{
|
||||
return _singleOrgPolicyNotEnabledErrorMessage;
|
||||
}
|
||||
|
||||
return await ValidateUserComplianceWithSingleOrgAsync(organizationId);
|
||||
}
|
||||
|
||||
private async Task<string> ValidateUserComplianceWithSingleOrgAsync(Guid organizationId)
|
||||
{
|
||||
var organizationUsers = (await organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId))
|
||||
.Where(ou => ou.Status != OrganizationUserStatusType.Invited &&
|
||||
ou.Status != OrganizationUserStatusType.Revoked &&
|
||||
ou.UserId.HasValue)
|
||||
.ToList();
|
||||
|
||||
if (organizationUsers.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync(
|
||||
organizationUsers.Select(ou => ou.UserId!.Value)))
|
||||
.Any(uo => uo.OrganizationId != organizationId &&
|
||||
uo.Status != OrganizationUserStatusType.Invited);
|
||||
.Any(uo => uo.OrganizationId != organizationId
|
||||
&& uo.Status != OrganizationUserStatusType.Invited);
|
||||
|
||||
return hasNonCompliantUser ? _usersNotCompliantWithSingleOrgErrorMessage : string.Empty;
|
||||
}
|
||||
|
||||
private async Task<string> ValidateNoProviderUsersAsync(Guid organizationId)
|
||||
private async Task<string> ValidateNoProviderUsersAsync(ICollection<OrganizationUserUserDetails> organizationUsers)
|
||||
{
|
||||
var providerUsers = await providerUserRepository.GetManyByOrganizationAsync(organizationId);
|
||||
var userIds = organizationUsers.Where(x => x.UserId is not null)
|
||||
.Select(x => x.UserId!.Value);
|
||||
|
||||
return providerUsers.Count > 0 ? _providerUsersExistErrorMessage : string.Empty;
|
||||
return (await providerUserRepository.GetManyByManyUsersAsync(userIds)).Count != 0
|
||||
? _providerUsersExistErrorMessage
|
||||
: string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,23 @@ namespace Bit.Core.Repositories;
|
||||
|
||||
public interface IOrganizationIntegrationConfigurationRepository : IRepository<OrganizationIntegrationConfiguration, Guid>
|
||||
{
|
||||
Task<List<OrganizationIntegrationConfigurationDetails>> GetConfigurationDetailsAsync(
|
||||
/// <summary>
|
||||
/// Retrieve the list of available configuration details for a specific event for the organization and
|
||||
/// integration type.<br/>
|
||||
/// <br/>
|
||||
/// <b>Note:</b> This returns all configurations that match the event type explicitly <b>and</b>
|
||||
/// all the configurations that have a null event type - null event type is considered a
|
||||
/// wildcard that matches all events.
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="eventType">The specific event type</param>
|
||||
/// <param name="organizationId">The id of the organization</param>
|
||||
/// <param name="integrationType">The integration type</param>
|
||||
/// <returns>A List of <see cref="OrganizationIntegrationConfigurationDetails"/> that match</returns>
|
||||
Task<List<OrganizationIntegrationConfigurationDetails>> GetManyByEventTypeOrganizationIdIntegrationType(
|
||||
EventType eventType,
|
||||
Guid organizationId,
|
||||
IntegrationType integrationType,
|
||||
EventType eventType);
|
||||
IntegrationType integrationType);
|
||||
|
||||
Task<List<OrganizationIntegrationConfigurationDetails>> GetAllConfigurationDetailsAsync();
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ public interface IProviderUserRepository : IRepository<ProviderUser, Guid>
|
||||
Task<int> GetCountByProviderAsync(Guid providerId, string email, bool onlyRegisteredUsers);
|
||||
Task<ICollection<ProviderUser>> GetManyAsync(IEnumerable<Guid> ids);
|
||||
Task<ICollection<ProviderUser>> GetManyByUserAsync(Guid userId);
|
||||
Task<ICollection<ProviderUser>> GetManyByManyUsersAsync(IEnumerable<Guid> userIds);
|
||||
Task<ProviderUser?> GetByProviderUserAsync(Guid providerId, Guid userId);
|
||||
Task<ICollection<ProviderUser>> GetManyByProviderAsync(Guid providerId, ProviderUserType? type = null);
|
||||
Task<ICollection<ProviderUserUserDetails>> GetManyDetailsByProviderAsync(Guid providerId, ProviderUserStatusType? status = null);
|
||||
|
||||
@@ -5,6 +5,7 @@ using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Utilities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Models.Data.Organizations;
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Utilities;
|
||||
@@ -17,8 +18,8 @@ public class EventIntegrationHandler<T>(
|
||||
IntegrationType integrationType,
|
||||
IEventIntegrationPublisher eventIntegrationPublisher,
|
||||
IIntegrationFilterService integrationFilterService,
|
||||
IIntegrationConfigurationDetailsCache configurationCache,
|
||||
IFusionCache cache,
|
||||
IOrganizationIntegrationConfigurationRepository configurationRepository,
|
||||
IGroupRepository groupRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
@@ -27,17 +28,7 @@ public class EventIntegrationHandler<T>(
|
||||
{
|
||||
public async Task HandleEventAsync(EventMessage eventMessage)
|
||||
{
|
||||
if (eventMessage.OrganizationId is not Guid organizationId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var configurations = configurationCache.GetConfigurationDetails(
|
||||
organizationId,
|
||||
integrationType,
|
||||
eventMessage.Type);
|
||||
|
||||
foreach (var configuration in configurations)
|
||||
foreach (var configuration in await GetConfigurationDetailsListAsync(eventMessage))
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -64,7 +55,7 @@ public class EventIntegrationHandler<T>(
|
||||
{
|
||||
IntegrationType = integrationType,
|
||||
MessageId = messageId.ToString(),
|
||||
OrganizationId = organizationId.ToString(),
|
||||
OrganizationId = eventMessage.OrganizationId?.ToString(),
|
||||
Configuration = config,
|
||||
RenderedTemplate = renderedTemplate,
|
||||
RetryCount = 0,
|
||||
@@ -132,6 +123,37 @@ public class EventIntegrationHandler<T>(
|
||||
return context;
|
||||
}
|
||||
|
||||
private async Task<List<OrganizationIntegrationConfigurationDetails>> GetConfigurationDetailsListAsync(EventMessage eventMessage)
|
||||
{
|
||||
if (eventMessage.OrganizationId is not Guid organizationId)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
List<OrganizationIntegrationConfigurationDetails> configurations = [];
|
||||
|
||||
var integrationTag = EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
|
||||
organizationId,
|
||||
integrationType
|
||||
);
|
||||
|
||||
configurations.AddRange(await cache.GetOrSetAsync<List<OrganizationIntegrationConfigurationDetails>>(
|
||||
key: EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails(
|
||||
organizationId: organizationId,
|
||||
integrationType: integrationType,
|
||||
eventType: eventMessage.Type),
|
||||
factory: async _ => await configurationRepository.GetManyByEventTypeOrganizationIdIntegrationType(
|
||||
eventType: eventMessage.Type,
|
||||
organizationId: organizationId,
|
||||
integrationType: integrationType),
|
||||
options: new FusionCacheEntryOptions(
|
||||
duration: EventIntegrationsCacheConstants.DurationForOrganizationIntegrationConfigurationDetails),
|
||||
tags: [integrationTag]
|
||||
));
|
||||
|
||||
return configurations;
|
||||
}
|
||||
|
||||
private async Task<OrganizationUserUserDetails?> GetUserFromCacheAsync(Guid organizationId, Guid userId) =>
|
||||
await cache.GetOrSetAsync<OrganizationUserUserDetails?>(
|
||||
key: EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationUser(organizationId, userId),
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data.Organizations;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Settings;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public class IntegrationConfigurationDetailsCacheService : BackgroundService, IIntegrationConfigurationDetailsCache
|
||||
{
|
||||
private readonly record struct IntegrationCacheKey(Guid OrganizationId, IntegrationType IntegrationType, EventType? EventType);
|
||||
private readonly IOrganizationIntegrationConfigurationRepository _repository;
|
||||
private readonly ILogger<IntegrationConfigurationDetailsCacheService> _logger;
|
||||
private readonly TimeSpan _refreshInterval;
|
||||
private Dictionary<IntegrationCacheKey, List<OrganizationIntegrationConfigurationDetails>> _cache = new();
|
||||
|
||||
public IntegrationConfigurationDetailsCacheService(
|
||||
IOrganizationIntegrationConfigurationRepository repository,
|
||||
GlobalSettings globalSettings,
|
||||
ILogger<IntegrationConfigurationDetailsCacheService> logger)
|
||||
{
|
||||
_repository = repository;
|
||||
_logger = logger;
|
||||
_refreshInterval = TimeSpan.FromMinutes(globalSettings.EventLogging.IntegrationCacheRefreshIntervalMinutes);
|
||||
}
|
||||
|
||||
public List<OrganizationIntegrationConfigurationDetails> GetConfigurationDetails(
|
||||
Guid organizationId,
|
||||
IntegrationType integrationType,
|
||||
EventType eventType)
|
||||
{
|
||||
var specificKey = new IntegrationCacheKey(organizationId, integrationType, eventType);
|
||||
var allEventsKey = new IntegrationCacheKey(organizationId, integrationType, null);
|
||||
|
||||
var results = new List<OrganizationIntegrationConfigurationDetails>();
|
||||
|
||||
if (_cache.TryGetValue(specificKey, out var specificConfigs))
|
||||
{
|
||||
results.AddRange(specificConfigs);
|
||||
}
|
||||
if (_cache.TryGetValue(allEventsKey, out var fallbackConfigs))
|
||||
{
|
||||
results.AddRange(fallbackConfigs);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await RefreshAsync();
|
||||
|
||||
var timer = new PeriodicTimer(_refreshInterval);
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||
{
|
||||
await RefreshAsync();
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task RefreshAsync()
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var newCache = (await _repository.GetAllConfigurationDetailsAsync())
|
||||
.GroupBy(x => new IntegrationCacheKey(x.OrganizationId, x.IntegrationType, x.EventType))
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
_cache = newCache;
|
||||
|
||||
stopwatch.Stop();
|
||||
_logger.LogInformation(
|
||||
"[IntegrationConfigurationDetailsCacheService] Refreshed successfully: {Count} entries in {Duration}ms",
|
||||
newCache.Count,
|
||||
stopwatch.Elapsed.TotalMilliseconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError("[IntegrationConfigurationDetailsCacheService] Refresh failed: {ex}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -295,33 +295,59 @@ graph TD
|
||||
```
|
||||
## Caching
|
||||
|
||||
To reduce database load and improve performance, integration configurations are cached in-memory as a Dictionary
|
||||
with a periodic load of all configurations. Without caching, each incoming `EventMessage` would trigger a database
|
||||
To reduce database load and improve performance, event integrations uses its own named extended cache (see
|
||||
the [README in Utilities](https://github.com/bitwarden/server/blob/main/src/Core/Utilities/README.md#extended-cache)
|
||||
for more information). Without caching, for instance, each incoming `EventMessage` would trigger a database
|
||||
query to retrieve the relevant `OrganizationIntegrationConfigurationDetails`.
|
||||
|
||||
By loading all configurations into memory on a fixed interval, we ensure:
|
||||
### `EventIntegrationsCacheConstants`
|
||||
|
||||
- Consistent performance for reads.
|
||||
- Reduced database pressure.
|
||||
- Predictable refresh timing, independent of event activity.
|
||||
`EventIntegrationsCacheConstants` allows the code to have strongly typed references to a number of cache-related
|
||||
details when working with the extended cache. The cache name and all cache keys and tags are programmatically accessed
|
||||
from `EventIntegrationsCacheConstants` rather than simple strings. For instance,
|
||||
`EventIntegrationsCacheConstants.CacheName` is used in the cache setup, keyed services, dependency injection, etc.,
|
||||
rather than using a string literal (i.e. "EventIntegrations") in code.
|
||||
|
||||
### Architecture / Design
|
||||
### `OrganizationIntegrationConfigurationDetails`
|
||||
|
||||
- The cache is read-only for consumers. It is only updated in bulk by a background refresh process.
|
||||
- The cache is fully replaced on each refresh to avoid locking or partial state.
|
||||
- This is one of the most actively used portions of the architecture because any event that has an associated
|
||||
organization requires a check of the configurations to determine if we need to fire off an integration.
|
||||
- By using the extended cache, all reads are hitting the L1 or L2 cache before needing to access the database.
|
||||
- Reads return a `List<OrganizationIntegrationConfigurationDetails>` for a given key or an empty list if no
|
||||
match exists.
|
||||
- Failures or delays in the loading process do not affect the existing cache state. The cache will continue serving
|
||||
the last known good state until the update replaces the whole cache.
|
||||
- The TTL is set very high on these records (1 day). This is because when the admin API makes any changes, it
|
||||
tells the cache to remove that key. This propagates to the event listening code via the extended cache backplane,
|
||||
which means that the cache is then expired and the next read will fetch the new values. This allows us to have
|
||||
a high TTL and avoid needing to refresh values except when necessary.
|
||||
|
||||
### Background Refresh
|
||||
#### Tagging per integration
|
||||
|
||||
A hosted service (`IntegrationConfigurationDetailsCacheService`) runs in the background and:
|
||||
- Each entry in the cache (which again, returns `List<OrganizationIntegrationConfigurationDetails>`) is tagged with
|
||||
the organization id and the integration type.
|
||||
- This allows us to remove all of a given organization's configuration details for an integration when the admin
|
||||
makes changes at the integration level.
|
||||
- For instance, if there were 5 events configured for a given organization's webhook and the admin changed the URL
|
||||
at the integration level, the updates would need to be propagated or else the cache will continue returning the
|
||||
stale URL.
|
||||
- By tagging each of the entries, the API can ask the extended cache to remove all the entries for a given
|
||||
organization integration in one call. The cache will handle dropping / refreshing these entries in a
|
||||
performant way.
|
||||
- There are two places in the code that are both aware of the tagging functionality
|
||||
- The `EventIntegrationHandler` must use the tag when fetching relevant configuration details. This tells the cache
|
||||
to store the entry with the tag when it successfully loads from the repository.
|
||||
- The `OrganizationIntegrationController` needs to use the tag to remove all the tagged entries when and admin
|
||||
creates, updates, or deletes an integration.
|
||||
- To ensure both places are synchronized on how to tag entries, they both use
|
||||
`EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration` to build the tag.
|
||||
|
||||
- Loads all configuration records at application startup.
|
||||
- Refreshes the cache on a configurable interval.
|
||||
- Logs timing and entry count on success.
|
||||
- Logs exceptions on failure without disrupting application flow.
|
||||
### Template Properties
|
||||
|
||||
- The `IntegrationTemplateProcessor` supports some properties that require an additional lookup. For instance,
|
||||
the `UserId` is provided as part of the `EventMessage`, but `UserName` means an additional lookup to map the user
|
||||
id to the actual name.
|
||||
- The properties for a `User` (which includes `ActingUser`), `Group`, and `Organization` are cached via the
|
||||
extended cache with a default TTL of 30 minutes.
|
||||
- This is cached in both the L1 (Memory) and L2 (Redis) and will be automatically refreshed as needed.
|
||||
|
||||
# Building a new integration
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
|
||||
@@ -455,9 +456,7 @@ public class RegisterUserCommand : IRegisterUserCommand
|
||||
else if (!string.IsNullOrEmpty(organization.DisplayName()))
|
||||
{
|
||||
// If the organization is Free or Families plan, send families welcome email
|
||||
if (organization.PlanType is PlanType.FamiliesAnnually
|
||||
or PlanType.FamiliesAnnually2019
|
||||
or PlanType.Free)
|
||||
if (organization.PlanType.GetProductTier() is ProductTierType.Free or ProductTierType.Families)
|
||||
{
|
||||
await _mailService.SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(user, organization.DisplayName());
|
||||
}
|
||||
|
||||
@@ -12,6 +12,12 @@ public static class StripeConstants
|
||||
public const string UnrecognizedLocation = "unrecognized_location";
|
||||
}
|
||||
|
||||
public static class BillingReasons
|
||||
{
|
||||
public const string SubscriptionCreate = "subscription_create";
|
||||
public const string SubscriptionCycle = "subscription_cycle";
|
||||
}
|
||||
|
||||
public static class CollectionMethod
|
||||
{
|
||||
public const string ChargeAutomatically = "charge_automatically";
|
||||
|
||||
@@ -142,6 +142,7 @@ public static class FeatureFlagKeys
|
||||
public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache";
|
||||
public const string BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration";
|
||||
public const string IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud";
|
||||
public const string BulkRevokeUsersV2 = "pm-28456-bulk-revoke-users-v2";
|
||||
|
||||
/* Architecture */
|
||||
public const string DesktopMigrationMilestone1 = "desktop-ui-migration-milestone-1";
|
||||
@@ -251,6 +252,7 @@ public static class FeatureFlagKeys
|
||||
public const string PM23904_RiskInsightsForPremium = "pm-23904-risk-insights-for-premium";
|
||||
public const string PM25083_AutofillConfirmFromSearch = "pm-25083-autofill-confirm-from-search";
|
||||
public const string VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders";
|
||||
public const string BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight";
|
||||
|
||||
/* Innovation Team */
|
||||
public const string ArchiveVaultItems = "pm-19148-innovation-archive";
|
||||
|
||||
@@ -53,11 +53,37 @@
|
||||
|
||||
<style type="text/css">
|
||||
|
||||
@media only screen and (max-width:480px) {
|
||||
.mj-bw-hero-responsive-img {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (max-width:480px) {
|
||||
.mj-bw-learn-more-footer-responsive-img {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (max-width:479px) {
|
||||
table.mj-full-width-mobile { width: 100% !important; }
|
||||
td.mj-full-width-mobile { width: auto !important; }
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (max-width:480px) {
|
||||
.mj-bw-icon-row-text {
|
||||
padding-left: 5px !important;
|
||||
line-height: 20px;
|
||||
}
|
||||
.mj-bw-icon-row {
|
||||
padding: 10px 15px;
|
||||
width: fit-content !important;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<style type="text/css">
|
||||
@@ -67,29 +93,8 @@
|
||||
.border-fix > table > tbody > tr > td {
|
||||
border-radius: 3px;
|
||||
}
|
||||
@media only screen and (max-width: 480px) {
|
||||
.hide-small-img {
|
||||
display: none !important;
|
||||
}
|
||||
.send-bubble {
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
width: 90% !important;
|
||||
}
|
||||
}
|
||||
@media only screen and (max-width: 480px) {
|
||||
.mj-bw-icon-row-text {
|
||||
padding-left: 5px !important;
|
||||
line-height: 20px;
|
||||
}
|
||||
.mj-bw-icon-row {
|
||||
padding: 10px 15px;
|
||||
width: fit-content !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<!-- Responsive icon visibility -->
|
||||
<!-- Responsive styling for mj-bw-icon-row -->
|
||||
|
||||
</head>
|
||||
<body style="word-spacing:normal;background-color:#e6e9ef;">
|
||||
|
||||
@@ -156,7 +161,7 @@
|
||||
</h1>
|
||||
<mj-text color="#fff" padding-top="0" padding-bottom="0">
|
||||
<h2 style="font-weight: normal; font-size: 16px; line-height: 0px">
|
||||
Let's get set up to autofill.
|
||||
Let’s get you set up to autofill.
|
||||
</h2>
|
||||
</mj-text></div>
|
||||
|
||||
@@ -176,7 +181,7 @@
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center" class="hide-small-img" style="font-size:0px;padding:0px;word-break:break-word;">
|
||||
<td align="center" class="mj-bw-hero-responsive-img" style="font-size:0px;padding:0px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
@@ -256,7 +261,7 @@
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 15px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;">A <b>{{OrganizationName}}</b> administrator will approve you
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;">An administrator from <b>{{OrganizationName}}</b> will approve you
|
||||
before you can share passwords. While you wait for approval, get
|
||||
started with Bitwarden Password Manager:</div>
|
||||
|
||||
@@ -622,10 +627,10 @@
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;"><p style="font-size: 18px; line-height: 28px; font-weight: bold;">
|
||||
Learn more about Bitwarden
|
||||
</p>
|
||||
Find user guides, product documentation, and videos on the
|
||||
<a href="https://bitwarden.com/help/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;"> Bitwarden Help Center</a>.</div>
|
||||
Learn more about Bitwarden
|
||||
</p>
|
||||
Find user guides, product documentation, and videos on the
|
||||
<a href="https://bitwarden.com/help/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;"> Bitwarden Help Center</a>.</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -643,7 +648,7 @@
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center" class="hide-small-img" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<td align="center" class="mj-bw-learn-more-footer-responsive-img" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
|
||||
@@ -53,11 +53,37 @@
|
||||
|
||||
<style type="text/css">
|
||||
|
||||
@media only screen and (max-width:480px) {
|
||||
.mj-bw-hero-responsive-img {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (max-width:480px) {
|
||||
.mj-bw-learn-more-footer-responsive-img {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (max-width:479px) {
|
||||
table.mj-full-width-mobile { width: 100% !important; }
|
||||
td.mj-full-width-mobile { width: auto !important; }
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (max-width:480px) {
|
||||
.mj-bw-icon-row-text {
|
||||
padding-left: 5px !important;
|
||||
line-height: 20px;
|
||||
}
|
||||
.mj-bw-icon-row {
|
||||
padding: 10px 15px;
|
||||
width: fit-content !important;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<style type="text/css">
|
||||
@@ -67,29 +93,8 @@
|
||||
.border-fix > table > tbody > tr > td {
|
||||
border-radius: 3px;
|
||||
}
|
||||
@media only screen and (max-width: 480px) {
|
||||
.hide-small-img {
|
||||
display: none !important;
|
||||
}
|
||||
.send-bubble {
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
width: 90% !important;
|
||||
}
|
||||
}
|
||||
@media only screen and (max-width: 480px) {
|
||||
.mj-bw-icon-row-text {
|
||||
padding-left: 5px !important;
|
||||
line-height: 20px;
|
||||
}
|
||||
.mj-bw-icon-row {
|
||||
padding: 10px 15px;
|
||||
width: fit-content !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<!-- Responsive icon visibility -->
|
||||
<!-- Responsive styling for mj-bw-icon-row -->
|
||||
|
||||
</head>
|
||||
<body style="word-spacing:normal;background-color:#e6e9ef;">
|
||||
|
||||
@@ -156,7 +161,7 @@
|
||||
</h1>
|
||||
<mj-text color="#fff" padding-top="0" padding-bottom="0">
|
||||
<h2 style="font-weight: normal; font-size: 16px; line-height: 0px">
|
||||
Let's get set up to autofill.
|
||||
Let’s get you set up to autofill.
|
||||
</h2>
|
||||
</mj-text></div>
|
||||
|
||||
@@ -176,7 +181,7 @@
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center" class="hide-small-img" style="font-size:0px;padding:0px;word-break:break-word;">
|
||||
<td align="center" class="mj-bw-hero-responsive-img" style="font-size:0px;padding:0px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
@@ -621,10 +626,10 @@
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;"><p style="font-size: 18px; line-height: 28px; font-weight: bold;">
|
||||
Learn more about Bitwarden
|
||||
</p>
|
||||
Find user guides, product documentation, and videos on the
|
||||
<a href="https://bitwarden.com/help/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;"> Bitwarden Help Center</a>.</div>
|
||||
Learn more about Bitwarden
|
||||
</p>
|
||||
Find user guides, product documentation, and videos on the
|
||||
<a href="https://bitwarden.com/help/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;"> Bitwarden Help Center</a>.</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -642,7 +647,7 @@
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center" class="hide-small-img" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<td align="center" class="mj-bw-learn-more-footer-responsive-img" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
|
||||
@@ -53,11 +53,37 @@
|
||||
|
||||
<style type="text/css">
|
||||
|
||||
@media only screen and (max-width:480px) {
|
||||
.mj-bw-hero-responsive-img {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (max-width:480px) {
|
||||
.mj-bw-learn-more-footer-responsive-img {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (max-width:479px) {
|
||||
table.mj-full-width-mobile { width: 100% !important; }
|
||||
td.mj-full-width-mobile { width: auto !important; }
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (max-width:480px) {
|
||||
.mj-bw-icon-row-text {
|
||||
padding-left: 5px !important;
|
||||
line-height: 20px;
|
||||
}
|
||||
.mj-bw-icon-row {
|
||||
padding: 10px 15px;
|
||||
width: fit-content !important;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<style type="text/css">
|
||||
@@ -67,29 +93,8 @@
|
||||
.border-fix > table > tbody > tr > td {
|
||||
border-radius: 3px;
|
||||
}
|
||||
@media only screen and (max-width: 480px) {
|
||||
.hide-small-img {
|
||||
display: none !important;
|
||||
}
|
||||
.send-bubble {
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
width: 90% !important;
|
||||
}
|
||||
}
|
||||
@media only screen and (max-width: 480px) {
|
||||
.mj-bw-icon-row-text {
|
||||
padding-left: 5px !important;
|
||||
line-height: 20px;
|
||||
}
|
||||
.mj-bw-icon-row {
|
||||
padding: 10px 15px;
|
||||
width: fit-content !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<!-- Responsive icon visibility -->
|
||||
<!-- Responsive styling for mj-bw-icon-row -->
|
||||
|
||||
</head>
|
||||
<body style="word-spacing:normal;background-color:#e6e9ef;">
|
||||
|
||||
@@ -156,7 +161,7 @@
|
||||
</h1>
|
||||
<mj-text color="#fff" padding-top="0" padding-bottom="0">
|
||||
<h2 style="font-weight: normal; font-size: 16px; line-height: 0px">
|
||||
Let's get set up to autofill.
|
||||
Let’s get you set up to autofill.
|
||||
</h2>
|
||||
</mj-text></div>
|
||||
|
||||
@@ -176,7 +181,7 @@
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center" class="hide-small-img" style="font-size:0px;padding:0px;word-break:break-word;">
|
||||
<td align="center" class="mj-bw-hero-responsive-img" style="font-size:0px;padding:0px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
@@ -256,7 +261,7 @@
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 15px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;">A <b>{{OrganizationName}}</b> administrator will need to confirm
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;">An administrator from <b>{{OrganizationName}}</b> will need to confirm
|
||||
you before you can share passwords. Get started with Bitwarden
|
||||
Password Manager:</div>
|
||||
|
||||
@@ -622,10 +627,10 @@
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;"><p style="font-size: 18px; line-height: 28px; font-weight: bold;">
|
||||
Learn more about Bitwarden
|
||||
</p>
|
||||
Find user guides, product documentation, and videos on the
|
||||
<a href="https://bitwarden.com/help/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;"> Bitwarden Help Center</a>.</div>
|
||||
Learn more about Bitwarden
|
||||
</p>
|
||||
Find user guides, product documentation, and videos on the
|
||||
<a href="https://bitwarden.com/help/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;"> Bitwarden Help Center</a>.</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -643,7 +648,7 @@
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center" class="hide-small-img" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<td align="center" class="mj-bw-learn-more-footer-responsive-img" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
|
||||
@@ -41,8 +41,10 @@ if (!fs.existsSync(config.outputDir)) {
|
||||
}
|
||||
}
|
||||
|
||||
// Find all MJML files with absolute path
|
||||
const mjmlFiles = glob.sync(`${config.inputDir}/**/*.mjml`);
|
||||
// Find all MJML files with absolute paths, excluding components directories
|
||||
const mjmlFiles = glob.sync(`${config.inputDir}/**/*.mjml`, {
|
||||
ignore: ['**/components/**']
|
||||
});
|
||||
|
||||
console.log(`\n[INFO] Found ${mjmlFiles.length} MJML file(s) to compile...`);
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ class MjBwIconRow extends BodyComponent {
|
||||
|
||||
static defaultAttributes = {};
|
||||
|
||||
componentHeadStyle = (breakpoint) => {
|
||||
headStyle = (breakpoint) => {
|
||||
return `
|
||||
@media only screen and (max-width:${breakpoint}) {
|
||||
.mj-bw-icon-row-text {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<mj-bw-hero
|
||||
img-src="https://assets.bitwarden.com/email/v1/account-fill.png"
|
||||
title="Welcome to Bitwarden!"
|
||||
sub-title="Let's get set up to autofill."
|
||||
sub-title="Let’s get you set up to autofill."
|
||||
/>
|
||||
</mj-wrapper>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<mj-bw-hero
|
||||
img-src="https://assets.bitwarden.com/email/v1/account-fill.png"
|
||||
title="Welcome to Bitwarden!"
|
||||
sub-title="Let's get set up to autofill."
|
||||
sub-title="Let’s get you set up to autofill."
|
||||
/>
|
||||
</mj-wrapper>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<mj-bw-hero
|
||||
img-src="https://assets.bitwarden.com/email/v1/account-fill.png"
|
||||
title="Welcome to Bitwarden!"
|
||||
sub-title="Let's get set up to autofill."
|
||||
sub-title="Let’s get you set up to autofill."
|
||||
/>
|
||||
</mj-wrapper>
|
||||
|
||||
|
||||
@@ -45,6 +45,9 @@ using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using V1_RevokeUsersCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1;
|
||||
using V2_RevokeUsersCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;
|
||||
|
||||
namespace Bit.Core.OrganizationFeatures;
|
||||
|
||||
public static class OrganizationServiceCollectionExtensions
|
||||
@@ -133,7 +136,6 @@ public static class OrganizationServiceCollectionExtensions
|
||||
{
|
||||
services.AddScoped<IRemoveOrganizationUserCommand, RemoveOrganizationUserCommand>();
|
||||
services.AddScoped<IRevokeNonCompliantOrganizationUserCommand, RevokeNonCompliantOrganizationUserCommand>();
|
||||
services.AddScoped<IRevokeOrganizationUserCommand, RevokeOrganizationUserCommand>();
|
||||
services.AddScoped<IUpdateOrganizationUserCommand, UpdateOrganizationUserCommand>();
|
||||
services.AddScoped<IUpdateOrganizationUserGroupsCommand, UpdateOrganizationUserGroupsCommand>();
|
||||
services.AddScoped<IConfirmOrganizationUserCommand, ConfirmOrganizationUserCommand>();
|
||||
@@ -143,6 +145,11 @@ public static class OrganizationServiceCollectionExtensions
|
||||
|
||||
services.AddScoped<IDeleteClaimedOrganizationUserAccountCommand, DeleteClaimedOrganizationUserAccountCommand>();
|
||||
services.AddScoped<IDeleteClaimedOrganizationUserAccountValidator, DeleteClaimedOrganizationUserAccountValidator>();
|
||||
|
||||
services.AddScoped<V1_RevokeUsersCommand.IRevokeOrganizationUserCommand, V1_RevokeUsersCommand.RevokeOrganizationUserCommand>();
|
||||
|
||||
services.AddScoped<V2_RevokeUsersCommand.IRevokeOrganizationUserCommand, V2_RevokeUsersCommand.RevokeOrganizationUserCommand>();
|
||||
services.AddScoped<V2_RevokeUsersCommand.IRevokeOrganizationUserValidator, V2_RevokeUsersCommand.RevokeOrganizationUsersValidator>();
|
||||
}
|
||||
|
||||
private static void AddOrganizationApiKeyCommandsQueries(this IServiceCollection services)
|
||||
@@ -197,6 +204,7 @@ public static class OrganizationServiceCollectionExtensions
|
||||
services.AddScoped<IInviteOrganizationUsersCommand, InviteOrganizationUsersCommand>();
|
||||
services.AddScoped<ISendOrganizationInvitesCommand, SendOrganizationInvitesCommand>();
|
||||
services.AddScoped<IResendOrganizationInviteCommand, ResendOrganizationInviteCommand>();
|
||||
services.AddScoped<IBulkResendOrganizationInvitesCommand, BulkResendOrganizationInvitesCommand>();
|
||||
|
||||
services.AddScoped<IInviteUsersValidator, InviteOrganizationUsersValidator>();
|
||||
services.AddScoped<IInviteUsersOrganizationValidator, InviteUsersOrganizationValidator>();
|
||||
|
||||
@@ -732,7 +732,7 @@ public class GlobalSettings : IGlobalSettings
|
||||
public class ExtendedCacheSettings
|
||||
{
|
||||
public bool EnableDistributedCache { get; set; } = true;
|
||||
public bool UseSharedRedisCache { get; set; } = true;
|
||||
public bool UseSharedDistributedCache { get; set; } = true;
|
||||
public IConnectionStringSettings Redis { get; set; } = new ConnectionStringSettings();
|
||||
public TimeSpan Duration { get; set; } = TimeSpan.FromMinutes(30);
|
||||
public bool IsFailSafeEnabled { get; set; } = true;
|
||||
|
||||
@@ -140,7 +140,7 @@ services.AddExtendedCache("MyFeatureCache", globalSettings, new GlobalSettings.E
|
||||
// Option 4: Isolated Redis for specialized features
|
||||
services.AddExtendedCache("SpecializedCache", globalSettings, new GlobalSettings.ExtendedCacheSettings
|
||||
{
|
||||
UseSharedRedisCache = false,
|
||||
UseSharedDistributedCache = false,
|
||||
Redis = new GlobalSettings.ConnectionStringSettings
|
||||
{
|
||||
ConnectionString = "localhost:6379,ssl=false"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data.Organizations;
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
|
||||
namespace Bit.Core.Utilities;
|
||||
@@ -11,7 +13,12 @@ public static class EventIntegrationsCacheConstants
|
||||
/// <summary>
|
||||
/// The base cache name used for storing event integration data.
|
||||
/// </summary>
|
||||
public static readonly string CacheName = "EventIntegrations";
|
||||
public const string CacheName = "EventIntegrations";
|
||||
|
||||
/// <summary>
|
||||
/// Duration TimeSpan for adding OrganizationIntegrationConfigurationDetails to the cache.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan DurationForOrganizationIntegrationConfigurationDetails = TimeSpan.FromDays(1);
|
||||
|
||||
/// <summary>
|
||||
/// Builds a deterministic cache key for a <see cref="Group"/>.
|
||||
@@ -20,10 +27,8 @@ public static class EventIntegrationsCacheConstants
|
||||
/// <returns>
|
||||
/// A cache key for this Group.
|
||||
/// </returns>
|
||||
public static string BuildCacheKeyForGroup(Guid groupId)
|
||||
{
|
||||
return $"Group:{groupId:N}";
|
||||
}
|
||||
public static string BuildCacheKeyForGroup(Guid groupId) =>
|
||||
$"Group:{groupId:N}";
|
||||
|
||||
/// <summary>
|
||||
/// Builds a deterministic cache key for an <see cref="Organization"/>.
|
||||
@@ -32,10 +37,8 @@ public static class EventIntegrationsCacheConstants
|
||||
/// <returns>
|
||||
/// A cache key for the Organization.
|
||||
/// </returns>
|
||||
public static string BuildCacheKeyForOrganization(Guid organizationId)
|
||||
{
|
||||
return $"Organization:{organizationId:N}";
|
||||
}
|
||||
public static string BuildCacheKeyForOrganization(Guid organizationId) =>
|
||||
$"Organization:{organizationId:N}";
|
||||
|
||||
/// <summary>
|
||||
/// Builds a deterministic cache key for an organization user <see cref="OrganizationUserUserDetails"/>.
|
||||
@@ -45,8 +48,37 @@ public static class EventIntegrationsCacheConstants
|
||||
/// <returns>
|
||||
/// A cache key for the user.
|
||||
/// </returns>
|
||||
public static string BuildCacheKeyForOrganizationUser(Guid organizationId, Guid userId)
|
||||
{
|
||||
return $"OrganizationUserUserDetails:{organizationId:N}:{userId:N}";
|
||||
}
|
||||
public static string BuildCacheKeyForOrganizationUser(Guid organizationId, Guid userId) =>
|
||||
$"OrganizationUserUserDetails:{organizationId:N}:{userId:N}";
|
||||
|
||||
/// <summary>
|
||||
/// Builds a deterministic cache key for an organization's integration configuration details
|
||||
/// <see cref="OrganizationIntegrationConfigurationDetails"/>.
|
||||
/// </summary>
|
||||
/// <param name="organizationId">The unique identifier of the organization to which the user belongs.</param>
|
||||
/// <param name="integrationType">The <see cref="IntegrationType"/> of the integration.</param>
|
||||
/// <param name="eventType">The <see cref="EventType"/> of the event configured. Can be null to apply to all events.</param>
|
||||
/// <returns>
|
||||
/// A cache key for the configuration details.
|
||||
/// </returns>
|
||||
public static string BuildCacheKeyForOrganizationIntegrationConfigurationDetails(
|
||||
Guid organizationId,
|
||||
IntegrationType integrationType,
|
||||
EventType? eventType
|
||||
) => $"OrganizationIntegrationConfigurationDetails:{organizationId:N}:{integrationType}:{eventType}";
|
||||
|
||||
/// <summary>
|
||||
/// Builds a deterministic tag for tagging an organization's integration configuration details. This tag is then
|
||||
/// used to tag all of the <see cref="OrganizationIntegrationConfigurationDetails"/> that result from this
|
||||
/// integration, which allows us to remove all relevant entries when an integration is changed or removed.
|
||||
/// </summary>
|
||||
/// <param name="organizationId">The unique identifier of the organization to which the user belongs.</param>
|
||||
/// <param name="integrationType">The <see cref="IntegrationType"/> of the integration.</param>
|
||||
/// <returns>
|
||||
/// A cache tag to use for the configuration details.
|
||||
/// </returns>
|
||||
public static string BuildCacheTagForOrganizationIntegration(
|
||||
Guid organizationId,
|
||||
IntegrationType integrationType
|
||||
) => $"OrganizationIntegration:{organizationId:N}:{integrationType}";
|
||||
}
|
||||
|
||||
@@ -18,9 +18,12 @@ public static class ExtendedCacheServiceCollectionExtensions
|
||||
/// Adds a new, named Fusion Cache <see href="https://github.com/ZiggyCreatures/FusionCache"/> to the service
|
||||
/// collection. If an existing cache of the same name is found, it will do nothing.<br/>
|
||||
/// <br/>
|
||||
/// <b>Note</b>: When re-using the existing Redis cache, it is expected to call this method <b>after</b> calling
|
||||
/// <code>services.AddDistributedCache(globalSettings)</code><br />This ensures that DI correctly finds,
|
||||
/// configures, and re-uses all the shared Redis architecture.
|
||||
/// <b>Note</b>: When re-using an existing distributed cache, it is expected to call this method <b>after</b> calling
|
||||
/// <code>services.AddDistributedCache(globalSettings)</code><br />This ensures that DI correctly finds
|
||||
/// and re-uses the shared distributed cache infrastructure.<br />
|
||||
/// <br />
|
||||
/// <b>Backplane</b>: Cross-instance cache invalidation is only available when using Redis.
|
||||
/// Non-Redis distributed caches operate with eventual consistency across multiple instances.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddExtendedCache(
|
||||
this IServiceCollection services,
|
||||
@@ -72,12 +75,21 @@ public static class ExtendedCacheServiceCollectionExtensions
|
||||
if (!settings.EnableDistributedCache)
|
||||
return services;
|
||||
|
||||
if (settings.UseSharedRedisCache)
|
||||
if (settings.UseSharedDistributedCache)
|
||||
{
|
||||
// Using Shared Redis, TryAdd and reuse all pieces (multiplexer, distributed cache and backplane)
|
||||
|
||||
if (!CoreHelpers.SettingHasValue(globalSettings.DistributedCache.Redis.ConnectionString))
|
||||
{
|
||||
// Using Shared Non-Redis Distributed Cache:
|
||||
// 1. Assume IDistributedCache is already registered (e.g., Cosmos, SQL Server)
|
||||
// 2. Backplane not supported (Redis-only feature, requires pub/sub)
|
||||
|
||||
fusionCacheBuilder
|
||||
.TryWithRegisteredDistributedCache();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
// Using Shared Redis, TryAdd and reuse all pieces (multiplexer, distributed cache and backplane)
|
||||
|
||||
services.TryAddSingleton<IConnectionMultiplexer>(sp =>
|
||||
CreateConnectionMultiplexer(sp, cacheName, globalSettings.DistributedCache.Redis.ConnectionString));
|
||||
@@ -92,13 +104,13 @@ public static class ExtendedCacheServiceCollectionExtensions
|
||||
});
|
||||
|
||||
services.TryAddSingleton<IFusionCacheBackplane>(sp =>
|
||||
{
|
||||
var mux = sp.GetRequiredService<IConnectionMultiplexer>();
|
||||
return new RedisBackplane(new RedisBackplaneOptions
|
||||
{
|
||||
var mux = sp.GetRequiredService<IConnectionMultiplexer>();
|
||||
return new RedisBackplane(new RedisBackplaneOptions
|
||||
{
|
||||
ConnectionMultiplexerFactory = () => Task.FromResult(mux)
|
||||
});
|
||||
ConnectionMultiplexerFactory = () => Task.FromResult(mux)
|
||||
});
|
||||
});
|
||||
|
||||
fusionCacheBuilder
|
||||
.WithRegisteredDistributedCache()
|
||||
@@ -107,10 +119,21 @@ public static class ExtendedCacheServiceCollectionExtensions
|
||||
return services;
|
||||
}
|
||||
|
||||
// Using keyed Redis / Distributed Cache. Create all pieces as keyed services.
|
||||
// Using keyed Distributed Cache. Create/Reuse all pieces as keyed services.
|
||||
|
||||
if (!CoreHelpers.SettingHasValue(settings.Redis.ConnectionString))
|
||||
{
|
||||
// Using Keyed Non-Redis Distributed Cache:
|
||||
// 1. Assume IDistributedCache (e.g., Cosmos, SQL Server) is already registered with cacheName as key
|
||||
// 2. Backplane not supported (Redis-only feature, requires pub/sub)
|
||||
|
||||
fusionCacheBuilder
|
||||
.TryWithRegisteredKeyedDistributedCache(serviceKey: cacheName);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
// Using Keyed Redis: TryAdd and reuse all pieces (multiplexer, distributed cache and backplane)
|
||||
|
||||
services.TryAddKeyedSingleton<IConnectionMultiplexer>(
|
||||
cacheName,
|
||||
|
||||
@@ -20,10 +20,9 @@ public class OrganizationIntegrationConfigurationRepository : Repository<Organiz
|
||||
: base(connectionString, readOnlyConnectionString)
|
||||
{ }
|
||||
|
||||
public async Task<List<OrganizationIntegrationConfigurationDetails>> GetConfigurationDetailsAsync(
|
||||
Guid organizationId,
|
||||
IntegrationType integrationType,
|
||||
EventType eventType)
|
||||
public async Task<List<OrganizationIntegrationConfigurationDetails>>
|
||||
GetManyByEventTypeOrganizationIdIntegrationType(EventType eventType, Guid organizationId,
|
||||
IntegrationType integrationType)
|
||||
{
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
{
|
||||
|
||||
@@ -625,7 +625,11 @@ public class OrganizationUserRepository : Repository<OrganizationUser, Guid>, IO
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"[dbo].[OrganizationUser_SetStatusForUsersByGuidIdArray]",
|
||||
new { OrganizationUserIds = organizationUserIds.ToGuidIdArrayTVP(), Status = OrganizationUserStatusType.Revoked },
|
||||
new
|
||||
{
|
||||
OrganizationUserIds = organizationUserIds.ToGuidIdArrayTVP(),
|
||||
Status = OrganizationUserStatusType.Revoked
|
||||
},
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,18 @@ public class ProviderUserRepository : Repository<ProviderUser, Guid>, IProviderU
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ICollection<ProviderUser>> GetManyByManyUsersAsync(IEnumerable<Guid> userIds)
|
||||
{
|
||||
await using var connection = new SqlConnection(ConnectionString);
|
||||
|
||||
var results = await connection.QueryAsync<ProviderUser>(
|
||||
"[dbo].[ProviderUser_ReadManyByManyUserIds]",
|
||||
new { UserIds = userIds.ToGuidIdArrayTVP() },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
return results.ToList();
|
||||
}
|
||||
|
||||
public async Task<ProviderUser?> GetByProviderUserAsync(Guid providerId, Guid userId)
|
||||
{
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
|
||||
@@ -17,16 +17,17 @@ public class OrganizationIntegrationConfigurationRepository : Repository<Core.Ad
|
||||
: base(serviceScopeFactory, mapper, context => context.OrganizationIntegrationConfigurations)
|
||||
{ }
|
||||
|
||||
public async Task<List<OrganizationIntegrationConfigurationDetails>> GetConfigurationDetailsAsync(
|
||||
Guid organizationId,
|
||||
IntegrationType integrationType,
|
||||
EventType eventType)
|
||||
public async Task<List<OrganizationIntegrationConfigurationDetails>>
|
||||
GetManyByEventTypeOrganizationIdIntegrationType(EventType eventType, Guid organizationId,
|
||||
IntegrationType integrationType)
|
||||
{
|
||||
using (var scope = ServiceScopeFactory.CreateScope())
|
||||
{
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
var query = new OrganizationIntegrationConfigurationDetailsReadManyByEventTypeOrganizationIdIntegrationTypeQuery(
|
||||
organizationId, eventType, integrationType
|
||||
organizationId,
|
||||
eventType,
|
||||
integrationType
|
||||
);
|
||||
return await query.Run(dbContext).ToListAsync();
|
||||
}
|
||||
|
||||
@@ -96,6 +96,20 @@ public class ProviderUserRepository :
|
||||
return await query.ToArrayAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ICollection<ProviderUser>> GetManyByManyUsersAsync(IEnumerable<Guid> userIds)
|
||||
{
|
||||
await using var scope = ServiceScopeFactory.CreateAsyncScope();
|
||||
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
|
||||
var query = from pu in dbContext.ProviderUsers
|
||||
where pu.UserId != null && userIds.Contains(pu.UserId.Value)
|
||||
select pu;
|
||||
|
||||
return await query.ToArrayAsync();
|
||||
}
|
||||
|
||||
public async Task<ProviderUser> GetByProviderUserAsync(Guid providerId, Guid userId)
|
||||
{
|
||||
using (var scope = ServiceScopeFactory.CreateScope())
|
||||
|
||||
@@ -1,31 +1,21 @@
|
||||
#nullable enable
|
||||
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data.Organizations;
|
||||
|
||||
namespace Bit.Infrastructure.EntityFramework.Repositories.Queries;
|
||||
|
||||
public class OrganizationIntegrationConfigurationDetailsReadManyByEventTypeOrganizationIdIntegrationTypeQuery : IQuery<OrganizationIntegrationConfigurationDetails>
|
||||
public class OrganizationIntegrationConfigurationDetailsReadManyByEventTypeOrganizationIdIntegrationTypeQuery(
|
||||
Guid organizationId,
|
||||
EventType eventType,
|
||||
IntegrationType integrationType)
|
||||
: IQuery<OrganizationIntegrationConfigurationDetails>
|
||||
{
|
||||
private readonly Guid _organizationId;
|
||||
private readonly EventType _eventType;
|
||||
private readonly IntegrationType _integrationType;
|
||||
|
||||
public OrganizationIntegrationConfigurationDetailsReadManyByEventTypeOrganizationIdIntegrationTypeQuery(Guid organizationId, EventType eventType, IntegrationType integrationType)
|
||||
{
|
||||
_organizationId = organizationId;
|
||||
_eventType = eventType;
|
||||
_integrationType = integrationType;
|
||||
}
|
||||
|
||||
public IQueryable<OrganizationIntegrationConfigurationDetails> Run(DatabaseContext dbContext)
|
||||
{
|
||||
var query = from oic in dbContext.OrganizationIntegrationConfigurations
|
||||
join oi in dbContext.OrganizationIntegrations on oic.OrganizationIntegrationId equals oi.Id into oioic
|
||||
from oi in dbContext.OrganizationIntegrations
|
||||
where oi.OrganizationId == _organizationId &&
|
||||
oi.Type == _integrationType &&
|
||||
oic.EventType == _eventType
|
||||
join oi in dbContext.OrganizationIntegrations on oic.OrganizationIntegrationId equals oi.Id
|
||||
where oi.OrganizationId == organizationId &&
|
||||
oi.Type == integrationType &&
|
||||
(oic.EventType == eventType || oic.EventType == null)
|
||||
select new OrganizationIntegrationConfigurationDetails()
|
||||
{
|
||||
Id = oic.Id,
|
||||
|
||||
@@ -893,13 +893,11 @@ public static class ServiceCollectionExtensions
|
||||
integrationType: listenerConfiguration.IntegrationType,
|
||||
eventIntegrationPublisher: provider.GetRequiredService<IEventIntegrationPublisher>(),
|
||||
integrationFilterService: provider.GetRequiredService<IIntegrationFilterService>(),
|
||||
configurationCache: provider.GetRequiredService<IIntegrationConfigurationDetailsCache>(),
|
||||
cache: provider.GetRequiredKeyedService<IFusionCache>(EventIntegrationsCacheConstants.CacheName),
|
||||
configurationRepository: provider.GetRequiredService<IOrganizationIntegrationConfigurationRepository>(),
|
||||
groupRepository: provider.GetRequiredService<IGroupRepository>(),
|
||||
organizationRepository: provider.GetRequiredService<IOrganizationRepository>(),
|
||||
organizationUserRepository: provider.GetRequiredService<IOrganizationUserRepository>(),
|
||||
logger: provider.GetRequiredService<ILogger<EventIntegrationHandler<TConfig>>>()
|
||||
)
|
||||
organizationUserRepository: provider.GetRequiredService<IOrganizationUserRepository>(), logger: provider.GetRequiredService<ILogger<EventIntegrationHandler<TConfig>>>())
|
||||
);
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService,
|
||||
AzureServiceBusEventListenerService<TListenerConfig>>(provider =>
|
||||
@@ -941,10 +939,6 @@ public static class ServiceCollectionExtensions
|
||||
// Add common services
|
||||
services.AddDistributedCache(globalSettings);
|
||||
services.AddExtendedCache(EventIntegrationsCacheConstants.CacheName, globalSettings);
|
||||
services.TryAddSingleton<IntegrationConfigurationDetailsCacheService>();
|
||||
services.TryAddSingleton<IIntegrationConfigurationDetailsCache>(provider =>
|
||||
provider.GetRequiredService<IntegrationConfigurationDetailsCacheService>());
|
||||
services.AddHostedService(provider => provider.GetRequiredService<IntegrationConfigurationDetailsCacheService>());
|
||||
services.TryAddSingleton<IIntegrationFilterService, IntegrationFilterService>();
|
||||
services.TryAddKeyedSingleton<IEventWriteService, RepositoryEventWriteService>("persistent");
|
||||
|
||||
@@ -1024,13 +1018,11 @@ public static class ServiceCollectionExtensions
|
||||
integrationType: listenerConfiguration.IntegrationType,
|
||||
eventIntegrationPublisher: provider.GetRequiredService<IEventIntegrationPublisher>(),
|
||||
integrationFilterService: provider.GetRequiredService<IIntegrationFilterService>(),
|
||||
configurationCache: provider.GetRequiredService<IIntegrationConfigurationDetailsCache>(),
|
||||
cache: provider.GetRequiredKeyedService<IFusionCache>(EventIntegrationsCacheConstants.CacheName),
|
||||
configurationRepository: provider.GetRequiredService<IOrganizationIntegrationConfigurationRepository>(),
|
||||
groupRepository: provider.GetRequiredService<IGroupRepository>(),
|
||||
organizationRepository: provider.GetRequiredService<IOrganizationRepository>(),
|
||||
organizationUserRepository: provider.GetRequiredService<IOrganizationUserRepository>(),
|
||||
logger: provider.GetRequiredService<ILogger<EventIntegrationHandler<TConfig>>>()
|
||||
)
|
||||
organizationUserRepository: provider.GetRequiredService<IOrganizationUserRepository>(), logger: provider.GetRequiredService<ILogger<EventIntegrationHandler<TConfig>>>())
|
||||
);
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService,
|
||||
RabbitMqEventListenerService<TListenerConfig>>(provider =>
|
||||
|
||||
@@ -11,7 +11,7 @@ BEGIN
|
||||
FROM
|
||||
[dbo].[OrganizationIntegrationConfigurationDetailsView] oic
|
||||
WHERE
|
||||
oic.[EventType] = @EventType
|
||||
(oic.[EventType] = @EventType OR oic.[EventType] IS NULL)
|
||||
AND
|
||||
oic.[OrganizationId] = @OrganizationId
|
||||
AND
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE PROCEDURE [dbo].[ProviderUser_ReadManyByManyUserIds]
|
||||
@UserIds AS [dbo].[GuidIdArray] READONLY
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
SELECT
|
||||
[pu].*
|
||||
FROM
|
||||
[dbo].[ProviderUserView] AS [pu]
|
||||
INNER JOIN
|
||||
@UserIds [u] ON [u].[Id] = [pu].[UserId]
|
||||
END
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Bit.Api.AdminConsole.Models.Request;
|
||||
using Bit.Api.IntegrationTest.Factories;
|
||||
using Bit.Api.IntegrationTest.Helpers;
|
||||
using Bit.Api.Models.Request;
|
||||
using Bit.Seeder.Recipes;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Bit.Api.IntegrationTest.AdminConsole.Controllers;
|
||||
|
||||
public class GroupsControllerPerformanceTests(ITestOutputHelper testOutputHelper)
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests PUT /organizations/{orgId}/groups/{id}
|
||||
/// </summary>
|
||||
[Theory(Skip = "Performance test")]
|
||||
[InlineData(10, 5)]
|
||||
//[InlineData(100, 10)]
|
||||
//[InlineData(1000, 20)]
|
||||
public async Task UpdateGroup_WithUsersAndCollections(int userCount, int collectionCount)
|
||||
{
|
||||
await using var factory = new SqlServerApiApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var db = factory.GetDatabaseContext();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db);
|
||||
var collectionsSeeder = new CollectionsRecipe(db);
|
||||
var groupsSeeder = new GroupsRecipe(db);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount);
|
||||
|
||||
var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList();
|
||||
var collectionIds = collectionsSeeder.AddToOrganization(orgId, collectionCount, orgUserIds, 0);
|
||||
var groupIds = groupsSeeder.AddToOrganization(orgId, 1, orgUserIds, 0);
|
||||
|
||||
var groupId = groupIds.First();
|
||||
|
||||
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
|
||||
|
||||
var updateRequest = new GroupRequestModel
|
||||
{
|
||||
Name = "Updated Group Name",
|
||||
Collections = collectionIds.Select(c => new SelectionReadOnlyRequestModel { Id = c, ReadOnly = false, HidePasswords = false, Manage = false }),
|
||||
Users = orgUserIds
|
||||
};
|
||||
|
||||
var requestContent = new StringContent(JsonSerializer.Serialize(updateRequest), Encoding.UTF8, "application/json");
|
||||
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
var response = await client.PutAsync($"/organizations/{orgId}/groups/{groupId}", requestContent);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
testOutputHelper.WriteLine($"PUT /organizations/{{orgId}}/groups/{{id}} - Users: {orgUserIds.Count}; Collections: {collectionIds.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
using System.Net;
|
||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
using Bit.Api.IntegrationTest.Factories;
|
||||
using Bit.Api.IntegrationTest.Helpers;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Api.IntegrationTest.AdminConsole.Controllers;
|
||||
|
||||
public class OrganizationUserControllerBulkRevokeTests : IClassFixture<ApiApplicationFactory>, IAsyncLifetime
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly ApiApplicationFactory _factory;
|
||||
private readonly LoginHelper _loginHelper;
|
||||
|
||||
private Organization _organization = null!;
|
||||
private string _ownerEmail = null!;
|
||||
|
||||
public OrganizationUserControllerBulkRevokeTests(ApiApplicationFactory apiFactory)
|
||||
{
|
||||
_factory = apiFactory;
|
||||
_factory.SubstituteService<IFeatureService>(featureService =>
|
||||
{
|
||||
featureService
|
||||
.IsEnabled(FeatureFlagKeys.BulkRevokeUsersV2)
|
||||
.Returns(true);
|
||||
});
|
||||
_client = _factory.CreateClient();
|
||||
_loginHelper = new LoginHelper(_factory, _client);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_ownerEmail = $"org-user-bulk-revoke-test-{Guid.NewGuid()}@bitwarden.com";
|
||||
await _factory.LoginWithNewAccount(_ownerEmail);
|
||||
|
||||
(_organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseMonthly,
|
||||
ownerEmail: _ownerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card);
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
_client.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BulkRevoke_Success()
|
||||
{
|
||||
var (ownerEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory,
|
||||
_organization.Id, OrganizationUserType.Owner);
|
||||
|
||||
await _loginHelper.LoginAsync(ownerEmail);
|
||||
|
||||
var (_, orgUser1) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, OrganizationUserType.User);
|
||||
var (_, orgUser2) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, OrganizationUserType.User);
|
||||
|
||||
var organizationUserRepository = _factory.GetService<IOrganizationUserRepository>();
|
||||
|
||||
var request = new OrganizationUserBulkRequestModel
|
||||
{
|
||||
Ids = [orgUser1.Id, orgUser2.Id]
|
||||
};
|
||||
|
||||
var httpResponse = await _client.PutAsJsonAsync($"organizations/{_organization.Id}/users/revoke", request);
|
||||
var content = await httpResponse.Content.ReadFromJsonAsync<ListResponseModel<OrganizationUserBulkResponseModel>>();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode);
|
||||
Assert.NotNull(content);
|
||||
Assert.Equal(2, content.Data.Count());
|
||||
Assert.All(content.Data, r => Assert.Empty(r.Error));
|
||||
|
||||
var actualUsers = await organizationUserRepository.GetManyAsync([orgUser1.Id, orgUser2.Id]);
|
||||
Assert.All(actualUsers, u => Assert.Equal(OrganizationUserStatusType.Revoked, u.Status));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BulkRevoke_AsAdmin_Success()
|
||||
{
|
||||
var (adminEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory,
|
||||
_organization.Id, OrganizationUserType.Admin);
|
||||
|
||||
await _loginHelper.LoginAsync(adminEmail);
|
||||
|
||||
var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, OrganizationUserType.User);
|
||||
|
||||
var request = new OrganizationUserBulkRequestModel
|
||||
{
|
||||
Ids = [orgUser.Id]
|
||||
};
|
||||
|
||||
var httpResponse = await _client.PutAsJsonAsync($"organizations/{_organization.Id}/users/revoke", request);
|
||||
var content = await httpResponse.Content.ReadFromJsonAsync<ListResponseModel<OrganizationUserBulkResponseModel>>();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode);
|
||||
Assert.NotNull(content);
|
||||
Assert.Single(content.Data);
|
||||
Assert.All(content.Data, r => Assert.Empty(r.Error));
|
||||
|
||||
var actualUser = await _factory.GetService<IOrganizationUserRepository>().GetByIdAsync(orgUser.Id);
|
||||
Assert.NotNull(actualUser);
|
||||
Assert.Equal(OrganizationUserStatusType.Revoked, actualUser.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BulkRevoke_CannotRevokeSelf_ReturnsError()
|
||||
{
|
||||
var (userEmail, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory,
|
||||
_organization.Id, OrganizationUserType.Admin);
|
||||
|
||||
await _loginHelper.LoginAsync(userEmail);
|
||||
|
||||
var request = new OrganizationUserBulkRequestModel
|
||||
{
|
||||
Ids = [orgUser.Id]
|
||||
};
|
||||
|
||||
var httpResponse = await _client.PutAsJsonAsync($"organizations/{_organization.Id}/users/revoke", request);
|
||||
var content = await httpResponse.Content.ReadFromJsonAsync<ListResponseModel<OrganizationUserBulkResponseModel>>();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode);
|
||||
Assert.NotNull(content);
|
||||
Assert.Single(content.Data);
|
||||
Assert.Contains(content.Data, r => r.Id == orgUser.Id && r.Error == "You cannot revoke yourself.");
|
||||
|
||||
var actualUser = await _factory.GetService<IOrganizationUserRepository>().GetByIdAsync(orgUser.Id);
|
||||
Assert.NotNull(actualUser);
|
||||
Assert.Equal(OrganizationUserStatusType.Confirmed, actualUser.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BulkRevoke_AlreadyRevoked_ReturnsError()
|
||||
{
|
||||
var (ownerEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory,
|
||||
_organization.Id, OrganizationUserType.Owner);
|
||||
|
||||
await _loginHelper.LoginAsync(ownerEmail);
|
||||
|
||||
var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, OrganizationUserType.User);
|
||||
|
||||
var organizationUserRepository = _factory.GetService<IOrganizationUserRepository>();
|
||||
|
||||
await organizationUserRepository.RevokeAsync(orgUser.Id);
|
||||
|
||||
var request = new OrganizationUserBulkRequestModel
|
||||
{
|
||||
Ids = [orgUser.Id]
|
||||
};
|
||||
|
||||
var httpResponse = await _client.PutAsJsonAsync($"organizations/{_organization.Id}/users/revoke", request);
|
||||
var content = await httpResponse.Content.ReadFromJsonAsync<ListResponseModel<OrganizationUserBulkResponseModel>>();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode);
|
||||
Assert.NotNull(content);
|
||||
Assert.Single(content.Data);
|
||||
Assert.Contains(content.Data, r => r.Id == orgUser.Id && r.Error == "Already revoked.");
|
||||
|
||||
var actualUser = await organizationUserRepository.GetByIdAsync(orgUser.Id);
|
||||
Assert.NotNull(actualUser);
|
||||
Assert.Equal(OrganizationUserStatusType.Revoked, actualUser.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BulkRevoke_AdminCannotRevokeOwner_ReturnsError()
|
||||
{
|
||||
var (adminEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory,
|
||||
_organization.Id, OrganizationUserType.Admin);
|
||||
|
||||
await _loginHelper.LoginAsync(adminEmail);
|
||||
|
||||
var (_, ownerOrgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, OrganizationUserType.Owner);
|
||||
|
||||
var request = new OrganizationUserBulkRequestModel
|
||||
{
|
||||
Ids = [ownerOrgUser.Id]
|
||||
};
|
||||
|
||||
var httpResponse = await _client.PutAsJsonAsync($"organizations/{_organization.Id}/users/revoke", request);
|
||||
var content = await httpResponse.Content.ReadFromJsonAsync<ListResponseModel<OrganizationUserBulkResponseModel>>();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode);
|
||||
Assert.NotNull(content);
|
||||
Assert.Single(content.Data);
|
||||
Assert.Contains(content.Data, r => r.Id == ownerOrgUser.Id && r.Error == "Only owners can revoke other owners.");
|
||||
|
||||
var actualUser = await _factory.GetService<IOrganizationUserRepository>().GetByIdAsync(ownerOrgUser.Id);
|
||||
Assert.NotNull(actualUser);
|
||||
Assert.Equal(OrganizationUserStatusType.Confirmed, actualUser.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BulkRevoke_MixedResults()
|
||||
{
|
||||
var (ownerEmail, requestingOwner) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory,
|
||||
_organization.Id, OrganizationUserType.Owner);
|
||||
|
||||
await _loginHelper.LoginAsync(ownerEmail);
|
||||
|
||||
var (_, validOrgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, OrganizationUserType.User);
|
||||
var (_, alreadyRevokedOrgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, OrganizationUserType.User);
|
||||
|
||||
var organizationUserRepository = _factory.GetService<IOrganizationUserRepository>();
|
||||
|
||||
await organizationUserRepository.RevokeAsync(alreadyRevokedOrgUser.Id);
|
||||
|
||||
var request = new OrganizationUserBulkRequestModel
|
||||
{
|
||||
Ids = [validOrgUser.Id, alreadyRevokedOrgUser.Id, requestingOwner.Id]
|
||||
};
|
||||
|
||||
var httpResponse = await _client.PutAsJsonAsync($"organizations/{_organization.Id}/users/revoke", request);
|
||||
var content = await httpResponse.Content.ReadFromJsonAsync<ListResponseModel<OrganizationUserBulkResponseModel>>();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode);
|
||||
Assert.NotNull(content);
|
||||
Assert.Equal(3, content.Data.Count());
|
||||
|
||||
Assert.Contains(content.Data, r => r.Id == validOrgUser.Id && r.Error == string.Empty);
|
||||
Assert.Contains(content.Data, r => r.Id == alreadyRevokedOrgUser.Id && r.Error == "Already revoked.");
|
||||
Assert.Contains(content.Data, r => r.Id == requestingOwner.Id && r.Error == "You cannot revoke yourself.");
|
||||
|
||||
var actualUsers = await organizationUserRepository.GetManyAsync([validOrgUser.Id, alreadyRevokedOrgUser.Id, requestingOwner.Id]);
|
||||
Assert.Equal(OrganizationUserStatusType.Revoked, actualUsers.First(u => u.Id == validOrgUser.Id).Status);
|
||||
Assert.Equal(OrganizationUserStatusType.Revoked, actualUsers.First(u => u.Id == alreadyRevokedOrgUser.Id).Status);
|
||||
Assert.Equal(OrganizationUserStatusType.Confirmed, actualUsers.First(u => u.Id == requestingOwner.Id).Status);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(OrganizationUserType.User)]
|
||||
[InlineData(OrganizationUserType.Custom)]
|
||||
public async Task BulkRevoke_WithoutManageUsersPermission_ReturnsForbidden(OrganizationUserType organizationUserType)
|
||||
{
|
||||
var (userEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory,
|
||||
_organization.Id, organizationUserType, new Permissions { ManageUsers = false });
|
||||
|
||||
await _loginHelper.LoginAsync(userEmail);
|
||||
|
||||
var request = new OrganizationUserBulkRequestModel
|
||||
{
|
||||
Ids = [Guid.NewGuid()]
|
||||
};
|
||||
|
||||
var httpResponse = await _client.PutAsJsonAsync($"organizations/{_organization.Id}/users/revoke", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, httpResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BulkRevoke_WithEmptyIds_ReturnsBadRequest()
|
||||
{
|
||||
await _loginHelper.LoginAsync(_ownerEmail);
|
||||
|
||||
var request = new OrganizationUserBulkRequestModel
|
||||
{
|
||||
Ids = []
|
||||
};
|
||||
|
||||
var httpResponse = await _client.PutAsJsonAsync($"organizations/{_organization.Id}/users/revoke", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, httpResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BulkRevoke_WithInvalidOrganizationId_ReturnsForbidden()
|
||||
{
|
||||
var (ownerEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory,
|
||||
_organization.Id, OrganizationUserType.Owner);
|
||||
|
||||
await _loginHelper.LoginAsync(ownerEmail);
|
||||
|
||||
var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, OrganizationUserType.User);
|
||||
|
||||
var invalidOrgId = Guid.NewGuid();
|
||||
|
||||
var request = new OrganizationUserBulkRequestModel
|
||||
{
|
||||
Ids = [orgUser.Id]
|
||||
};
|
||||
|
||||
var httpResponse = await _client.PutAsJsonAsync($"organizations/{invalidOrgId}/users/revoke", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, httpResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BulkRevoke_ProviderRevokesOwner_ReturnsOk()
|
||||
{
|
||||
var providerEmail = $"provider-user{Guid.NewGuid()}@example.com";
|
||||
|
||||
// create user for provider
|
||||
await _factory.LoginWithNewAccount(providerEmail);
|
||||
|
||||
// create provider and provider user
|
||||
await _factory.GetService<ICreateProviderCommand>()
|
||||
.CreateBusinessUnitAsync(
|
||||
new Provider
|
||||
{
|
||||
Name = "provider",
|
||||
Type = ProviderType.BusinessUnit
|
||||
},
|
||||
providerEmail,
|
||||
PlanType.EnterpriseAnnually2023,
|
||||
10);
|
||||
|
||||
await _loginHelper.LoginAsync(providerEmail);
|
||||
|
||||
var providerUserUser = await _factory.GetService<IUserRepository>().GetByEmailAsync(providerEmail);
|
||||
|
||||
var providerUserCollection = await _factory.GetService<IProviderUserRepository>()
|
||||
.GetManyByUserAsync(providerUserUser!.Id);
|
||||
|
||||
var providerUser = providerUserCollection.First();
|
||||
|
||||
await _factory.GetService<IProviderOrganizationRepository>().CreateAsync(new ProviderOrganization
|
||||
{
|
||||
ProviderId = providerUser.ProviderId,
|
||||
OrganizationId = _organization.Id,
|
||||
Key = null,
|
||||
Settings = null
|
||||
});
|
||||
|
||||
var (_, ownerOrgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory,
|
||||
_organization.Id, OrganizationUserType.Owner);
|
||||
|
||||
var request = new OrganizationUserBulkRequestModel
|
||||
{
|
||||
Ids = [ownerOrgUser.Id]
|
||||
};
|
||||
|
||||
var httpResponse = await _client.PutAsJsonAsync($"organizations/{_organization.Id}/users/revoke", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +1,593 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
using Bit.Api.IntegrationTest.Factories;
|
||||
using Bit.Api.IntegrationTest.Helpers;
|
||||
using Bit.Api.Models.Request;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Seeder.Recipes;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Bit.Api.IntegrationTest.AdminConsole.Controllers;
|
||||
|
||||
public class OrganizationUsersControllerPerformanceTest(ITestOutputHelper testOutputHelper)
|
||||
public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testOutputHelper)
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests GET /organizations/{orgId}/users?includeCollections=true
|
||||
/// </summary>
|
||||
[Theory(Skip = "Performance test")]
|
||||
[InlineData(100)]
|
||||
[InlineData(60000)]
|
||||
public async Task GetAsync(int seats)
|
||||
[InlineData(10)]
|
||||
//[InlineData(100)]
|
||||
//[InlineData(1000)]
|
||||
public async Task GetAllUsers_WithCollections(int seats)
|
||||
{
|
||||
await using var factory = new SqlServerApiApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var db = factory.GetDatabaseContext();
|
||||
var seeder = new OrganizationWithUsersRecipe(db);
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db);
|
||||
var collectionsSeeder = new CollectionsRecipe(db);
|
||||
var groupsSeeder = new GroupsRecipe(db);
|
||||
|
||||
var orgId = seeder.Seed("Org", seats, "large.test");
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
|
||||
var tokens = await factory.LoginAsync("admin@large.test", "c55hlJ/cfdvTd4awTXUqow6X3cOQCfGwn11o3HblnPs=");
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Token);
|
||||
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: seats);
|
||||
|
||||
var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList();
|
||||
collectionsSeeder.AddToOrganization(orgId, 10, orgUserIds);
|
||||
groupsSeeder.AddToOrganization(orgId, 5, orgUserIds);
|
||||
|
||||
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
|
||||
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
var response = await client.GetAsync($"/organizations/{orgId}/users?includeCollections=true");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadAsStringAsync();
|
||||
Assert.NotEmpty(result);
|
||||
stopwatch.Stop();
|
||||
testOutputHelper.WriteLine($"GET /users - Seats: {seats}; Request duration: {stopwatch.ElapsedMilliseconds} ms");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests GET /organizations/{orgId}/users/mini-details
|
||||
/// </summary>
|
||||
[Theory(Skip = "Performance test")]
|
||||
[InlineData(10)]
|
||||
//[InlineData(100)]
|
||||
//[InlineData(1000)]
|
||||
public async Task GetAllUsers_MiniDetails(int seats)
|
||||
{
|
||||
await using var factory = new SqlServerApiApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var db = factory.GetDatabaseContext();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db);
|
||||
var collectionsSeeder = new CollectionsRecipe(db);
|
||||
var groupsSeeder = new GroupsRecipe(db);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: seats);
|
||||
|
||||
var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList();
|
||||
collectionsSeeder.AddToOrganization(orgId, 10, orgUserIds);
|
||||
groupsSeeder.AddToOrganization(orgId, 5, orgUserIds);
|
||||
|
||||
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
|
||||
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
var response = await client.GetAsync($"/organizations/{orgId}/users/mini-details");
|
||||
|
||||
stopwatch.Stop();
|
||||
testOutputHelper.WriteLine($"Seed: {seats}; Request duration: {stopwatch.ElapsedMilliseconds} ms");
|
||||
|
||||
testOutputHelper.WriteLine($"GET /users/mini-details - Seats: {seats}; Request duration: {stopwatch.ElapsedMilliseconds} ms");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests GET /organizations/{orgId}/users/{id}?includeGroups=true
|
||||
/// </summary>
|
||||
[Fact(Skip = "Performance test")]
|
||||
public async Task GetSingleUser_WithGroups()
|
||||
{
|
||||
await using var factory = new SqlServerApiApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var db = factory.GetDatabaseContext();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db);
|
||||
var groupsSeeder = new GroupsRecipe(db);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1);
|
||||
|
||||
var orgUserId = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).FirstOrDefault();
|
||||
groupsSeeder.AddToOrganization(orgId, 2, [orgUserId]);
|
||||
|
||||
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
|
||||
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
var response = await client.GetAsync($"/organizations/{orgId}/users/{orgUserId}?includeGroups=true");
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
testOutputHelper.WriteLine($"GET /users/{{id}} - Request duration: {stopwatch.ElapsedMilliseconds} ms");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests GET /organizations/{orgId}/users/{id}/reset-password-details
|
||||
/// </summary>
|
||||
[Fact(Skip = "Performance test")]
|
||||
public async Task GetResetPasswordDetails_ForSingleUser()
|
||||
{
|
||||
await using var factory = new SqlServerApiApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var db = factory.GetDatabaseContext();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1);
|
||||
|
||||
var orgUserId = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).FirstOrDefault();
|
||||
|
||||
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
|
||||
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
var response = await client.GetAsync($"/organizations/{orgId}/users/{orgUserId}/reset-password-details");
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
testOutputHelper.WriteLine($"GET /users/{{id}}/reset-password-details - Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests POST /organizations/{orgId}/users/confirm
|
||||
/// </summary>
|
||||
[Theory(Skip = "Performance test")]
|
||||
[InlineData(10)]
|
||||
//[InlineData(100)]
|
||||
//[InlineData(1000)]
|
||||
public async Task BulkConfirmUsers(int userCount)
|
||||
{
|
||||
await using var factory = new SqlServerApiApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var db = factory.GetDatabaseContext();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
var orgId = orgSeeder.Seed(
|
||||
name: "Org",
|
||||
domain: domain,
|
||||
users: userCount,
|
||||
usersStatus: OrganizationUserStatusType.Accepted);
|
||||
|
||||
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
|
||||
|
||||
var acceptedUserIds = db.OrganizationUsers
|
||||
.Where(ou => ou.OrganizationId == orgId && ou.Status == OrganizationUserStatusType.Accepted)
|
||||
.Select(ou => ou.Id)
|
||||
.ToList();
|
||||
|
||||
var confirmRequest = new OrganizationUserBulkConfirmRequestModel
|
||||
{
|
||||
Keys = acceptedUserIds.Select(id => new OrganizationUserBulkConfirmRequestModelEntry { Id = id, Key = "test-key-" + id }),
|
||||
DefaultUserCollectionName = "2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk="
|
||||
};
|
||||
|
||||
var requestContent = new StringContent(JsonSerializer.Serialize(confirmRequest), Encoding.UTF8, "application/json");
|
||||
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
var response = await client.PostAsync($"/organizations/{orgId}/users/confirm", requestContent);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
testOutputHelper.WriteLine($"POST /users/confirm - Users: {acceptedUserIds.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}");
|
||||
|
||||
Assert.True(response.IsSuccessStatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests POST /organizations/{orgId}/users/remove
|
||||
/// </summary>
|
||||
[Theory(Skip = "Performance test")]
|
||||
[InlineData(10)]
|
||||
//[InlineData(100)]
|
||||
//[InlineData(1000)]
|
||||
public async Task BulkRemoveUsers(int userCount)
|
||||
{
|
||||
await using var factory = new SqlServerApiApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var db = factory.GetDatabaseContext();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount);
|
||||
|
||||
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
|
||||
|
||||
var usersToRemove = db.OrganizationUsers
|
||||
.Where(ou => ou.OrganizationId == orgId && ou.Type == OrganizationUserType.User)
|
||||
.Select(ou => ou.Id)
|
||||
.ToList();
|
||||
|
||||
var removeRequest = new OrganizationUserBulkRequestModel { Ids = usersToRemove };
|
||||
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
var requestContent = new StringContent(JsonSerializer.Serialize(removeRequest), Encoding.UTF8, "application/json");
|
||||
|
||||
var response = await client.PostAsync($"/organizations/{orgId}/users/remove", requestContent);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
testOutputHelper.WriteLine($"POST /users/remove - Users: {usersToRemove.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}");
|
||||
|
||||
Assert.True(response.IsSuccessStatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests PUT /organizations/{orgId}/users/revoke
|
||||
/// </summary>
|
||||
[Theory(Skip = "Performance test")]
|
||||
[InlineData(10)]
|
||||
//[InlineData(100)]
|
||||
//[InlineData(1000)]
|
||||
public async Task BulkRevokeUsers(int userCount)
|
||||
{
|
||||
await using var factory = new SqlServerApiApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var db = factory.GetDatabaseContext();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
var orgId = orgSeeder.Seed(
|
||||
name: "Org",
|
||||
domain: domain,
|
||||
users: userCount,
|
||||
usersStatus: OrganizationUserStatusType.Confirmed);
|
||||
|
||||
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
|
||||
|
||||
var usersToRevoke = db.OrganizationUsers
|
||||
.Where(ou => ou.OrganizationId == orgId && ou.Type == OrganizationUserType.User)
|
||||
.Select(ou => ou.Id)
|
||||
.ToList();
|
||||
|
||||
var revokeRequest = new OrganizationUserBulkRequestModel { Ids = usersToRevoke };
|
||||
|
||||
var requestContent = new StringContent(JsonSerializer.Serialize(revokeRequest), Encoding.UTF8, "application/json");
|
||||
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
var response = await client.PutAsync($"/organizations/{orgId}/users/revoke", requestContent);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
testOutputHelper.WriteLine($"PUT /users/revoke - Users: {usersToRevoke.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}");
|
||||
|
||||
Assert.True(response.IsSuccessStatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests PUT /organizations/{orgId}/users/restore
|
||||
/// </summary>
|
||||
[Theory(Skip = "Performance test")]
|
||||
[InlineData(10)]
|
||||
//[InlineData(100)]
|
||||
//[InlineData(1000)]
|
||||
public async Task BulkRestoreUsers(int userCount)
|
||||
{
|
||||
await using var factory = new SqlServerApiApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var db = factory.GetDatabaseContext();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
var orgId = orgSeeder.Seed(
|
||||
name: "Org",
|
||||
domain: domain,
|
||||
users: userCount,
|
||||
usersStatus: OrganizationUserStatusType.Revoked);
|
||||
|
||||
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
|
||||
|
||||
var usersToRestore = db.OrganizationUsers
|
||||
.Where(ou => ou.OrganizationId == orgId && ou.Type == OrganizationUserType.User)
|
||||
.Select(ou => ou.Id)
|
||||
.ToList();
|
||||
|
||||
var restoreRequest = new OrganizationUserBulkRequestModel { Ids = usersToRestore };
|
||||
|
||||
var requestContent = new StringContent(JsonSerializer.Serialize(restoreRequest), Encoding.UTF8, "application/json");
|
||||
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
var response = await client.PutAsync($"/organizations/{orgId}/users/restore", requestContent);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
testOutputHelper.WriteLine($"PUT /users/restore - Users: {usersToRestore.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}");
|
||||
|
||||
Assert.True(response.IsSuccessStatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests POST /organizations/{orgId}/users/delete-account
|
||||
/// </summary>
|
||||
[Theory(Skip = "Performance test")]
|
||||
[InlineData(10)]
|
||||
//[InlineData(100)]
|
||||
//[InlineData(1000)]
|
||||
public async Task BulkDeleteAccounts(int userCount)
|
||||
{
|
||||
await using var factory = new SqlServerApiApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var db = factory.GetDatabaseContext();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db);
|
||||
var domainSeeder = new OrganizationDomainRecipe(db);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
|
||||
var orgId = orgSeeder.Seed(
|
||||
name: "Org",
|
||||
domain: domain,
|
||||
users: userCount,
|
||||
usersStatus: OrganizationUserStatusType.Confirmed);
|
||||
|
||||
domainSeeder.AddVerifiedDomainToOrganization(orgId, domain);
|
||||
|
||||
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
|
||||
|
||||
var usersToDelete = db.OrganizationUsers
|
||||
.Where(ou => ou.OrganizationId == orgId && ou.Type == OrganizationUserType.User)
|
||||
.Select(ou => ou.Id)
|
||||
.ToList();
|
||||
|
||||
var deleteRequest = new OrganizationUserBulkRequestModel { Ids = usersToDelete };
|
||||
|
||||
var requestContent = new StringContent(JsonSerializer.Serialize(deleteRequest), Encoding.UTF8, "application/json");
|
||||
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
var response = await client.PostAsync($"/organizations/{orgId}/users/delete-account", requestContent);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
testOutputHelper.WriteLine($"POST /users/delete-account - Users: {usersToDelete.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}");
|
||||
|
||||
Assert.True(response.IsSuccessStatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests PUT /organizations/{orgId}/users/{id}
|
||||
/// </summary>
|
||||
[Fact(Skip = "Performance test")]
|
||||
public async Task UpdateSingleUser_WithCollectionsAndGroups()
|
||||
{
|
||||
await using var factory = new SqlServerApiApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var db = factory.GetDatabaseContext();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db);
|
||||
var collectionsSeeder = new CollectionsRecipe(db);
|
||||
var groupsSeeder = new GroupsRecipe(db);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1);
|
||||
|
||||
var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList();
|
||||
var collectionIds = collectionsSeeder.AddToOrganization(orgId, 3, orgUserIds, 0);
|
||||
var groupIds = groupsSeeder.AddToOrganization(orgId, 2, orgUserIds, 0);
|
||||
|
||||
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
|
||||
|
||||
var userToUpdate = db.OrganizationUsers
|
||||
.FirstOrDefault(ou => ou.OrganizationId == orgId && ou.Type == OrganizationUserType.User);
|
||||
|
||||
var updateRequest = new OrganizationUserUpdateRequestModel
|
||||
{
|
||||
Type = OrganizationUserType.Custom,
|
||||
Collections = collectionIds.Select(c => new SelectionReadOnlyRequestModel { Id = c, ReadOnly = false, HidePasswords = false, Manage = false }),
|
||||
Groups = groupIds,
|
||||
AccessSecretsManager = false,
|
||||
Permissions = new Permissions { AccessEventLogs = true }
|
||||
};
|
||||
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
var response = await client.PutAsync($"/organizations/{orgId}/users/{userToUpdate.Id}",
|
||||
new StringContent(JsonSerializer.Serialize(updateRequest), Encoding.UTF8, "application/json"));
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
testOutputHelper.WriteLine($"PUT /users/{{id}} - Collections: {collectionIds.Count}; Groups: {groupIds.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}");
|
||||
|
||||
Assert.True(response.IsSuccessStatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests PUT /organizations/{orgId}/users/enable-secrets-manager
|
||||
/// </summary>
|
||||
[Theory(Skip = "Performance test")]
|
||||
[InlineData(10)]
|
||||
//[InlineData(100)]
|
||||
//[InlineData(1000)]
|
||||
public async Task BulkEnableSecretsManager(int userCount)
|
||||
{
|
||||
await using var factory = new SqlServerApiApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var db = factory.GetDatabaseContext();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount);
|
||||
|
||||
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
|
||||
|
||||
var usersToEnable = db.OrganizationUsers
|
||||
.Where(ou => ou.OrganizationId == orgId && ou.Type == OrganizationUserType.User)
|
||||
.Select(ou => ou.Id)
|
||||
.ToList();
|
||||
|
||||
var enableRequest = new OrganizationUserBulkRequestModel { Ids = usersToEnable };
|
||||
|
||||
var requestContent = new StringContent(JsonSerializer.Serialize(enableRequest), Encoding.UTF8, "application/json");
|
||||
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
var response = await client.PutAsync($"/organizations/{orgId}/users/enable-secrets-manager", requestContent);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
testOutputHelper.WriteLine($"PUT /users/enable-secrets-manager - Users: {usersToEnable.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}");
|
||||
|
||||
Assert.True(response.IsSuccessStatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests DELETE /organizations/{orgId}/users/{id}/delete-account
|
||||
/// </summary>
|
||||
[Fact(Skip = "Performance test")]
|
||||
public async Task DeleteSingleUserAccount_FromVerifiedDomain()
|
||||
{
|
||||
await using var factory = new SqlServerApiApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var db = factory.GetDatabaseContext();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db);
|
||||
var domainSeeder = new OrganizationDomainRecipe(db);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
var orgId = orgSeeder.Seed(
|
||||
name: "Org",
|
||||
domain: domain,
|
||||
users: 2,
|
||||
usersStatus: OrganizationUserStatusType.Confirmed);
|
||||
|
||||
domainSeeder.AddVerifiedDomainToOrganization(orgId, domain);
|
||||
|
||||
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
|
||||
|
||||
var userToDelete = db.OrganizationUsers
|
||||
.FirstOrDefault(ou => ou.OrganizationId == orgId && ou.Type == OrganizationUserType.User);
|
||||
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
var response = await client.DeleteAsync($"/organizations/{orgId}/users/{userToDelete.Id}/delete-account");
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
testOutputHelper.WriteLine($"DELETE /users/{{id}}/delete-account - Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests POST /organizations/{orgId}/users/invite
|
||||
/// </summary>
|
||||
[Theory(Skip = "Performance test")]
|
||||
[InlineData(1)]
|
||||
//[InlineData(5)]
|
||||
//[InlineData(20)]
|
||||
public async Task InviteUsers(int emailCount)
|
||||
{
|
||||
await using var factory = new SqlServerApiApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var db = factory.GetDatabaseContext();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db);
|
||||
var collectionsSeeder = new CollectionsRecipe(db);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1);
|
||||
|
||||
var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList();
|
||||
var collectionIds = collectionsSeeder.AddToOrganization(orgId, 2, orgUserIds, 0);
|
||||
|
||||
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
|
||||
|
||||
var emails = Enumerable.Range(0, emailCount).Select(i => $"{i:D4}@{domain}").ToArray();
|
||||
var inviteRequest = new OrganizationUserInviteRequestModel
|
||||
{
|
||||
Emails = emails,
|
||||
Type = OrganizationUserType.User,
|
||||
AccessSecretsManager = false,
|
||||
Collections = Array.Empty<SelectionReadOnlyRequestModel>(),
|
||||
Groups = Array.Empty<Guid>(),
|
||||
Permissions = null
|
||||
};
|
||||
|
||||
var requestContent = new StringContent(JsonSerializer.Serialize(inviteRequest), Encoding.UTF8, "application/json");
|
||||
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
var response = await client.PostAsync($"/organizations/{orgId}/users/invite", requestContent);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
testOutputHelper.WriteLine($"POST /users/invite - Emails: {emails.Length}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests POST /organizations/{orgId}/users/reinvite
|
||||
/// </summary>
|
||||
[Theory(Skip = "Performance test")]
|
||||
[InlineData(10)]
|
||||
//[InlineData(100)]
|
||||
//[InlineData(1000)]
|
||||
public async Task BulkReinviteUsers(int userCount)
|
||||
{
|
||||
await using var factory = new SqlServerApiApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var db = factory.GetDatabaseContext();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
var orgId = orgSeeder.Seed(
|
||||
name: "Org",
|
||||
domain: domain,
|
||||
users: userCount,
|
||||
usersStatus: OrganizationUserStatusType.Invited);
|
||||
|
||||
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
|
||||
|
||||
var usersToReinvite = db.OrganizationUsers
|
||||
.Where(ou => ou.OrganizationId == orgId && ou.Status == OrganizationUserStatusType.Invited)
|
||||
.Select(ou => ou.Id)
|
||||
.ToList();
|
||||
|
||||
var reinviteRequest = new OrganizationUserBulkRequestModel { Ids = usersToReinvite };
|
||||
|
||||
var requestContent = new StringContent(JsonSerializer.Serialize(reinviteRequest), Encoding.UTF8, "application/json");
|
||||
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
var response = await client.PostAsync($"/organizations/{orgId}/users/reinvite", requestContent);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
testOutputHelper.WriteLine($"POST /users/reinvite - Users: {usersToReinvite.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}");
|
||||
|
||||
Assert.True(response.IsSuccessStatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
using Bit.Api.Auth.Models.Request.Accounts;
|
||||
using Bit.Api.IntegrationTest.Factories;
|
||||
using Bit.Api.IntegrationTest.Helpers;
|
||||
using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Tokens;
|
||||
using Bit.Seeder.Recipes;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Bit.Api.IntegrationTest.AdminConsole.Controllers;
|
||||
|
||||
public class OrganizationsControllerPerformanceTests(ITestOutputHelper testOutputHelper)
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests DELETE /organizations/{id} with password verification
|
||||
/// </summary>
|
||||
[Theory(Skip = "Performance test")]
|
||||
[InlineData(10, 5, 3)]
|
||||
//[InlineData(100, 20, 10)]
|
||||
//[InlineData(1000, 50, 25)]
|
||||
public async Task DeleteOrganization_WithPasswordVerification(int userCount, int collectionCount, int groupCount)
|
||||
{
|
||||
await using var factory = new SqlServerApiApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var db = factory.GetDatabaseContext();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db);
|
||||
var collectionsSeeder = new CollectionsRecipe(db);
|
||||
var groupsSeeder = new GroupsRecipe(db);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount);
|
||||
|
||||
var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList();
|
||||
collectionsSeeder.AddToOrganization(orgId, collectionCount, orgUserIds, 0);
|
||||
groupsSeeder.AddToOrganization(orgId, groupCount, orgUserIds, 0);
|
||||
|
||||
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
|
||||
|
||||
var deleteRequest = new SecretVerificationRequestModel
|
||||
{
|
||||
MasterPasswordHash = "c55hlJ/cfdvTd4awTXUqow6X3cOQCfGwn11o3HblnPs="
|
||||
};
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Delete, $"/organizations/{orgId}")
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(deleteRequest), Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
testOutputHelper.WriteLine($"DELETE /organizations/{{id}} - Users: {userCount}; Collections: {collectionCount}; Groups: {groupCount}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests POST /organizations/{id}/delete-recover-token with token verification
|
||||
/// </summary>
|
||||
[Theory(Skip = "Performance test")]
|
||||
[InlineData(10, 5, 3)]
|
||||
//[InlineData(100, 20, 10)]
|
||||
//[InlineData(1000, 50, 25)]
|
||||
public async Task DeleteOrganization_WithTokenVerification(int userCount, int collectionCount, int groupCount)
|
||||
{
|
||||
await using var factory = new SqlServerApiApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var db = factory.GetDatabaseContext();
|
||||
var orgSeeder = new OrganizationWithUsersRecipe(db);
|
||||
var collectionsSeeder = new CollectionsRecipe(db);
|
||||
var groupsSeeder = new GroupsRecipe(db);
|
||||
|
||||
var domain = OrganizationTestHelpers.GenerateRandomDomain();
|
||||
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount);
|
||||
|
||||
var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList();
|
||||
collectionsSeeder.AddToOrganization(orgId, collectionCount, orgUserIds, 0);
|
||||
groupsSeeder.AddToOrganization(orgId, groupCount, orgUserIds, 0);
|
||||
|
||||
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
|
||||
|
||||
var organization = db.Organizations.FirstOrDefault(o => o.Id == orgId);
|
||||
Assert.NotNull(organization);
|
||||
|
||||
var tokenFactory = factory.GetService<IDataProtectorTokenFactory<OrgDeleteTokenable>>();
|
||||
var tokenable = new OrgDeleteTokenable(organization, 24);
|
||||
var token = tokenFactory.Protect(tokenable);
|
||||
|
||||
var deleteRequest = new OrganizationVerifyDeleteRecoverRequestModel
|
||||
{
|
||||
Token = token
|
||||
};
|
||||
|
||||
var requestContent = new StringContent(JsonSerializer.Serialize(deleteRequest), Encoding.UTF8, "application/json");
|
||||
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
var response = await client.PostAsync($"/organizations/{orgId}/delete-recover-token", requestContent);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
testOutputHelper.WriteLine($"POST /organizations/{{id}}/delete-recover-token - Users: {userCount}; Collections: {collectionCount}; Groups: {groupCount}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests POST /organizations/create-without-payment
|
||||
/// </summary>
|
||||
[Fact(Skip = "Performance test")]
|
||||
public async Task CreateOrganization_WithoutPayment()
|
||||
{
|
||||
await using var factory = new SqlServerApiApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var email = $"user@{OrganizationTestHelpers.GenerateRandomDomain()}";
|
||||
var masterPasswordHash = "c55hlJ/cfdvTd4awTXUqow6X3cOQCfGwn11o3HblnPs=";
|
||||
|
||||
await factory.LoginWithNewAccount(email, masterPasswordHash);
|
||||
|
||||
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, email, masterPasswordHash);
|
||||
|
||||
var createRequest = new OrganizationNoPaymentCreateRequest
|
||||
{
|
||||
Name = "Test Organization",
|
||||
BusinessName = "Test Business Name",
|
||||
BillingEmail = email,
|
||||
PlanType = PlanType.EnterpriseAnnually,
|
||||
Key = "2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk=",
|
||||
AdditionalSeats = 1,
|
||||
AdditionalStorageGb = 1,
|
||||
UseSecretsManager = true,
|
||||
AdditionalSmSeats = 1,
|
||||
AdditionalServiceAccounts = 2,
|
||||
MaxAutoscaleSeats = 100,
|
||||
PremiumAccessAddon = false,
|
||||
CollectionName = "2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk="
|
||||
};
|
||||
|
||||
var requestContent = new StringContent(JsonSerializer.Serialize(createRequest), Encoding.UTF8, "application/json");
|
||||
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
var response = await client.PostAsync("/organizations/create-without-payment", requestContent);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
testOutputHelper.WriteLine($"POST /organizations/create-without-payment - AdditionalSeats: {createRequest.AdditionalSeats}; AdditionalStorageGb: {createRequest.AdditionalStorageGb}; AdditionalSmSeats: {createRequest.AdditionalSmSeats}; AdditionalServiceAccounts: {createRequest.AdditionalServiceAccounts}; MaxAutoscaleSeats: {createRequest.MaxAutoscaleSeats}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -192,6 +192,15 @@ public static class OrganizationTestHelpers
|
||||
await policyRepository.CreateAsync(policy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a unique random domain name for testing purposes.
|
||||
/// </summary>
|
||||
/// <returns>A domain string like "a1b2c3d4.com"</returns>
|
||||
public static string GenerateRandomDomain()
|
||||
{
|
||||
return $"{Guid.NewGuid().ToString("N").Substring(0, 8)}.com";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a user account without a Master Password and adds them as a member to the specified organization.
|
||||
/// </summary>
|
||||
|
||||
32
test/Api.IntegrationTest/Helpers/PerformanceTestHelpers.cs
Normal file
32
test/Api.IntegrationTest/Helpers/PerformanceTestHelpers.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using System.Net.Http.Headers;
|
||||
using Bit.Api.IntegrationTest.Factories;
|
||||
|
||||
namespace Bit.Api.IntegrationTest.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Helper methods for performance tests to reduce code duplication.
|
||||
/// </summary>
|
||||
public static class PerformanceTestHelpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Standard password hash used across performance tests.
|
||||
/// </summary>
|
||||
public const string StandardPasswordHash = "c55hlJ/cfdvTd4awTXUqow6X3cOQCfGwn11o3HblnPs=";
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates an HttpClient with a bearer token for the specified user.
|
||||
/// </summary>
|
||||
/// <param name="factory">The application factory to use for login.</param>
|
||||
/// <param name="client">The HttpClient to authenticate.</param>
|
||||
/// <param name="email">The user's email address.</param>
|
||||
/// <param name="masterPasswordHash">The user's master password hash. Defaults to StandardPasswordHash.</param>
|
||||
public static async Task AuthenticateClientAsync(
|
||||
SqlServerApiApplicationFactory factory,
|
||||
HttpClient client,
|
||||
string email,
|
||||
string? masterPasswordHash = null)
|
||||
{
|
||||
var tokens = await factory.LoginAsync(email, masterPasswordHash ?? StandardPasswordHash);
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Token);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
@@ -730,4 +731,68 @@ public class OrganizationUsersControllerTests
|
||||
var problemResult = Assert.IsType<JsonHttpResult<ErrorResponseModel>>(result);
|
||||
Assert.Equal(StatusCodes.Status500InternalServerError, problemResult.StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task BulkReinvite_WhenFeatureFlagEnabled_UsesBulkResendOrganizationInvitesCommand(
|
||||
Guid organizationId,
|
||||
OrganizationUserBulkRequestModel bulkRequestModel,
|
||||
List<OrganizationUser> organizationUsers,
|
||||
Guid userId,
|
||||
SutProvider<OrganizationUsersController> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<ICurrentContext>().ManageUsers(organizationId).Returns(true);
|
||||
sutProvider.GetDependency<IUserService>().GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.IncreaseBulkReinviteLimitForCloud)
|
||||
.Returns(true);
|
||||
|
||||
var expectedResults = organizationUsers.Select(u => Tuple.Create(u, "")).ToList();
|
||||
sutProvider.GetDependency<IBulkResendOrganizationInvitesCommand>()
|
||||
.BulkResendInvitesAsync(organizationId, userId, bulkRequestModel.Ids)
|
||||
.Returns(expectedResults);
|
||||
|
||||
// Act
|
||||
var response = await sutProvider.Sut.BulkReinvite(organizationId, bulkRequestModel);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(organizationUsers.Count, response.Data.Count());
|
||||
|
||||
await sutProvider.GetDependency<IBulkResendOrganizationInvitesCommand>()
|
||||
.Received(1)
|
||||
.BulkResendInvitesAsync(organizationId, userId, bulkRequestModel.Ids);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task BulkReinvite_WhenFeatureFlagDisabled_UsesLegacyOrganizationService(
|
||||
Guid organizationId,
|
||||
OrganizationUserBulkRequestModel bulkRequestModel,
|
||||
List<OrganizationUser> organizationUsers,
|
||||
Guid userId,
|
||||
SutProvider<OrganizationUsersController> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<ICurrentContext>().ManageUsers(organizationId).Returns(true);
|
||||
sutProvider.GetDependency<IUserService>().GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.IncreaseBulkReinviteLimitForCloud)
|
||||
.Returns(false);
|
||||
|
||||
var expectedResults = organizationUsers.Select(u => Tuple.Create(u, "")).ToList();
|
||||
sutProvider.GetDependency<IOrganizationService>()
|
||||
.ResendInvitesAsync(organizationId, userId, bulkRequestModel.Ids)
|
||||
.Returns(expectedResults);
|
||||
|
||||
// Act
|
||||
var response = await sutProvider.Sut.BulkReinvite(organizationId, bulkRequestModel);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(organizationUsers.Count, response.Data.Count());
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationService>()
|
||||
.Received(1)
|
||||
.ResendInvitesAsync(organizationId, userId, bulkRequestModel.Ids);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using Bit.Core;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Models.Business;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.KeyManagement.Queries.Interfaces;
|
||||
@@ -30,6 +31,7 @@ public class AccountsControllerTests : IDisposable
|
||||
private readonly IPaymentService _paymentService;
|
||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||
private readonly IUserAccountKeysQuery _userAccountKeysQuery;
|
||||
private readonly ILicensingService _licensingService;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly AccountsController _sut;
|
||||
|
||||
@@ -40,13 +42,15 @@ public class AccountsControllerTests : IDisposable
|
||||
_paymentService = Substitute.For<IPaymentService>();
|
||||
_twoFactorIsEnabledQuery = Substitute.For<ITwoFactorIsEnabledQuery>();
|
||||
_userAccountKeysQuery = Substitute.For<IUserAccountKeysQuery>();
|
||||
_licensingService = Substitute.For<ILicensingService>();
|
||||
_globalSettings = new GlobalSettings { SelfHosted = false };
|
||||
|
||||
_sut = new AccountsController(
|
||||
_userService,
|
||||
_twoFactorIsEnabledQuery,
|
||||
_userAccountKeysQuery,
|
||||
_featureService
|
||||
_featureService,
|
||||
_licensingService
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
388
test/Billing.Test/Jobs/SubscriptionCancellationJobTests.cs
Normal file
388
test/Billing.Test/Jobs/SubscriptionCancellationJobTests.cs
Normal file
@@ -0,0 +1,388 @@
|
||||
using Bit.Billing.Constants;
|
||||
using Bit.Billing.Jobs;
|
||||
using Bit.Billing.Services;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Repositories;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Quartz;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Billing.Test.Jobs;
|
||||
|
||||
public class SubscriptionCancellationJobTests
|
||||
{
|
||||
private readonly IStripeFacade _stripeFacade;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly SubscriptionCancellationJob _sut;
|
||||
|
||||
public SubscriptionCancellationJobTests()
|
||||
{
|
||||
_stripeFacade = Substitute.For<IStripeFacade>();
|
||||
_organizationRepository = Substitute.For<IOrganizationRepository>();
|
||||
_sut = new SubscriptionCancellationJob(_stripeFacade, _organizationRepository, Substitute.For<ILogger<SubscriptionCancellationJob>>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_OrganizationIsNull_SkipsCancellation()
|
||||
{
|
||||
// Arrange
|
||||
const string subscriptionId = "sub_123";
|
||||
var organizationId = Guid.NewGuid();
|
||||
var context = CreateJobExecutionContext(subscriptionId, organizationId);
|
||||
|
||||
_organizationRepository.GetByIdAsync(organizationId).Returns((Organization)null);
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.DidNotReceiveWithAnyArgs().GetSubscription(Arg.Any<string>(), Arg.Any<SubscriptionGetOptions>());
|
||||
await _stripeFacade.DidNotReceiveWithAnyArgs().CancelSubscription(Arg.Any<string>(), Arg.Any<SubscriptionCancelOptions>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_OrganizationIsEnabled_SkipsCancellation()
|
||||
{
|
||||
// Arrange
|
||||
const string subscriptionId = "sub_123";
|
||||
var organizationId = Guid.NewGuid();
|
||||
var context = CreateJobExecutionContext(subscriptionId, organizationId);
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
Enabled = true
|
||||
};
|
||||
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.DidNotReceiveWithAnyArgs().GetSubscription(Arg.Any<string>(), Arg.Any<SubscriptionGetOptions>());
|
||||
await _stripeFacade.DidNotReceiveWithAnyArgs().CancelSubscription(Arg.Any<string>(), Arg.Any<SubscriptionCancelOptions>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_SubscriptionStatusIsNotUnpaid_SkipsCancellation()
|
||||
{
|
||||
// Arrange
|
||||
const string subscriptionId = "sub_123";
|
||||
var organizationId = Guid.NewGuid();
|
||||
var context = CreateJobExecutionContext(subscriptionId, organizationId);
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
Enabled = false
|
||||
};
|
||||
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Status = StripeSubscriptionStatus.Active,
|
||||
LatestInvoice = new Invoice
|
||||
{
|
||||
BillingReason = "subscription_cycle"
|
||||
}
|
||||
};
|
||||
_stripeFacade.GetSubscription(subscriptionId, Arg.Is<SubscriptionGetOptions>(o => o.Expand.Contains("latest_invoice")))
|
||||
.Returns(subscription);
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.DidNotReceive().CancelSubscription(subscriptionId, Arg.Any<SubscriptionCancelOptions>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_BillingReasonIsInvalid_SkipsCancellation()
|
||||
{
|
||||
// Arrange
|
||||
const string subscriptionId = "sub_123";
|
||||
var organizationId = Guid.NewGuid();
|
||||
var context = CreateJobExecutionContext(subscriptionId, organizationId);
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
Enabled = false
|
||||
};
|
||||
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Status = StripeSubscriptionStatus.Unpaid,
|
||||
LatestInvoice = new Invoice
|
||||
{
|
||||
BillingReason = "manual"
|
||||
}
|
||||
};
|
||||
_stripeFacade.GetSubscription(subscriptionId, Arg.Is<SubscriptionGetOptions>(o => o.Expand.Contains("latest_invoice")))
|
||||
.Returns(subscription);
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.DidNotReceive().CancelSubscription(subscriptionId, Arg.Any<SubscriptionCancelOptions>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_ValidConditions_CancelsSubscriptionAndVoidsInvoices()
|
||||
{
|
||||
// Arrange
|
||||
const string subscriptionId = "sub_123";
|
||||
var organizationId = Guid.NewGuid();
|
||||
var context = CreateJobExecutionContext(subscriptionId, organizationId);
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
Enabled = false
|
||||
};
|
||||
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Status = StripeSubscriptionStatus.Unpaid,
|
||||
LatestInvoice = new Invoice
|
||||
{
|
||||
BillingReason = "subscription_cycle"
|
||||
}
|
||||
};
|
||||
_stripeFacade.GetSubscription(subscriptionId, Arg.Is<SubscriptionGetOptions>(o => o.Expand.Contains("latest_invoice")))
|
||||
.Returns(subscription);
|
||||
|
||||
var invoices = new StripeList<Invoice>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new Invoice { Id = "inv_1" },
|
||||
new Invoice { Id = "inv_2" }
|
||||
],
|
||||
HasMore = false
|
||||
};
|
||||
_stripeFacade.ListInvoices(Arg.Any<InvoiceListOptions>()).Returns(invoices);
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.Received(1).CancelSubscription(subscriptionId, Arg.Any<SubscriptionCancelOptions>());
|
||||
await _stripeFacade.Received(1).VoidInvoice("inv_1");
|
||||
await _stripeFacade.Received(1).VoidInvoice("inv_2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_WithSubscriptionCreateBillingReason_CancelsSubscription()
|
||||
{
|
||||
// Arrange
|
||||
const string subscriptionId = "sub_123";
|
||||
var organizationId = Guid.NewGuid();
|
||||
var context = CreateJobExecutionContext(subscriptionId, organizationId);
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
Enabled = false
|
||||
};
|
||||
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Status = StripeSubscriptionStatus.Unpaid,
|
||||
LatestInvoice = new Invoice
|
||||
{
|
||||
BillingReason = "subscription_create"
|
||||
}
|
||||
};
|
||||
_stripeFacade.GetSubscription(subscriptionId, Arg.Is<SubscriptionGetOptions>(o => o.Expand.Contains("latest_invoice")))
|
||||
.Returns(subscription);
|
||||
|
||||
var invoices = new StripeList<Invoice>
|
||||
{
|
||||
Data = [],
|
||||
HasMore = false
|
||||
};
|
||||
_stripeFacade.ListInvoices(Arg.Any<InvoiceListOptions>()).Returns(invoices);
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.Received(1).CancelSubscription(subscriptionId, Arg.Any<SubscriptionCancelOptions>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_NoOpenInvoices_CancelsSubscriptionOnly()
|
||||
{
|
||||
// Arrange
|
||||
const string subscriptionId = "sub_123";
|
||||
var organizationId = Guid.NewGuid();
|
||||
var context = CreateJobExecutionContext(subscriptionId, organizationId);
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
Enabled = false
|
||||
};
|
||||
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Status = StripeSubscriptionStatus.Unpaid,
|
||||
LatestInvoice = new Invoice
|
||||
{
|
||||
BillingReason = "subscription_cycle"
|
||||
}
|
||||
};
|
||||
_stripeFacade.GetSubscription(subscriptionId, Arg.Is<SubscriptionGetOptions>(o => o.Expand.Contains("latest_invoice")))
|
||||
.Returns(subscription);
|
||||
|
||||
var invoices = new StripeList<Invoice>
|
||||
{
|
||||
Data = [],
|
||||
HasMore = false
|
||||
};
|
||||
_stripeFacade.ListInvoices(Arg.Any<InvoiceListOptions>()).Returns(invoices);
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.Received(1).CancelSubscription(subscriptionId, Arg.Any<SubscriptionCancelOptions>());
|
||||
await _stripeFacade.DidNotReceiveWithAnyArgs().VoidInvoice(Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_WithPagination_VoidsAllInvoices()
|
||||
{
|
||||
// Arrange
|
||||
const string subscriptionId = "sub_123";
|
||||
var organizationId = Guid.NewGuid();
|
||||
var context = CreateJobExecutionContext(subscriptionId, organizationId);
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
Enabled = false
|
||||
};
|
||||
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Status = StripeSubscriptionStatus.Unpaid,
|
||||
LatestInvoice = new Invoice
|
||||
{
|
||||
BillingReason = "subscription_cycle"
|
||||
}
|
||||
};
|
||||
_stripeFacade.GetSubscription(subscriptionId, Arg.Is<SubscriptionGetOptions>(o => o.Expand.Contains("latest_invoice")))
|
||||
.Returns(subscription);
|
||||
|
||||
// First page of invoices
|
||||
var firstPage = new StripeList<Invoice>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new Invoice { Id = "inv_1" },
|
||||
new Invoice { Id = "inv_2" }
|
||||
],
|
||||
HasMore = true
|
||||
};
|
||||
|
||||
// Second page of invoices
|
||||
var secondPage = new StripeList<Invoice>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new Invoice { Id = "inv_3" },
|
||||
new Invoice { Id = "inv_4" }
|
||||
],
|
||||
HasMore = false
|
||||
};
|
||||
|
||||
_stripeFacade.ListInvoices(Arg.Is<InvoiceListOptions>(o => o.StartingAfter == null))
|
||||
.Returns(firstPage);
|
||||
_stripeFacade.ListInvoices(Arg.Is<InvoiceListOptions>(o => o.StartingAfter == "inv_2"))
|
||||
.Returns(secondPage);
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.Received(1).CancelSubscription(subscriptionId, Arg.Any<SubscriptionCancelOptions>());
|
||||
await _stripeFacade.Received(1).VoidInvoice("inv_1");
|
||||
await _stripeFacade.Received(1).VoidInvoice("inv_2");
|
||||
await _stripeFacade.Received(1).VoidInvoice("inv_3");
|
||||
await _stripeFacade.Received(1).VoidInvoice("inv_4");
|
||||
await _stripeFacade.Received(2).ListInvoices(Arg.Any<InvoiceListOptions>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_ListInvoicesCalledWithCorrectOptions()
|
||||
{
|
||||
// Arrange
|
||||
const string subscriptionId = "sub_123";
|
||||
var organizationId = Guid.NewGuid();
|
||||
var context = CreateJobExecutionContext(subscriptionId, organizationId);
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
Enabled = false
|
||||
};
|
||||
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Status = StripeSubscriptionStatus.Unpaid,
|
||||
LatestInvoice = new Invoice
|
||||
{
|
||||
BillingReason = "subscription_cycle"
|
||||
}
|
||||
};
|
||||
_stripeFacade.GetSubscription(subscriptionId, Arg.Is<SubscriptionGetOptions>(o => o.Expand.Contains("latest_invoice")))
|
||||
.Returns(subscription);
|
||||
|
||||
var invoices = new StripeList<Invoice>
|
||||
{
|
||||
Data = [],
|
||||
HasMore = false
|
||||
};
|
||||
_stripeFacade.ListInvoices(Arg.Any<InvoiceListOptions>()).Returns(invoices);
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.Received(1).GetSubscription(subscriptionId, Arg.Is<SubscriptionGetOptions>(o => o.Expand.Contains("latest_invoice")));
|
||||
await _stripeFacade.Received(1).ListInvoices(Arg.Is<InvoiceListOptions>(o =>
|
||||
o.Status == "open" &&
|
||||
o.Subscription == subscriptionId &&
|
||||
o.Limit == 100));
|
||||
}
|
||||
|
||||
private static IJobExecutionContext CreateJobExecutionContext(string subscriptionId, Guid organizationId)
|
||||
{
|
||||
var context = Substitute.For<IJobExecutionContext>();
|
||||
var jobDataMap = new JobDataMap
|
||||
{
|
||||
{ "subscriptionId", subscriptionId },
|
||||
{ "organizationId", organizationId.ToString() }
|
||||
};
|
||||
context.MergedJobDataMap.Returns(jobDataMap);
|
||||
return context;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class BulkResendOrganizationInvitesCommandTests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task BulkResendInvitesAsync_ValidatesUsersAndSendsBatchInvite(
|
||||
Organization organization,
|
||||
OrganizationUser validUser1,
|
||||
OrganizationUser validUser2,
|
||||
OrganizationUser acceptedUser,
|
||||
OrganizationUser wrongOrgUser,
|
||||
SutProvider<BulkResendOrganizationInvitesCommand> sutProvider)
|
||||
{
|
||||
validUser1.OrganizationId = organization.Id;
|
||||
validUser1.Status = OrganizationUserStatusType.Invited;
|
||||
validUser2.OrganizationId = organization.Id;
|
||||
validUser2.Status = OrganizationUserStatusType.Invited;
|
||||
acceptedUser.OrganizationId = organization.Id;
|
||||
acceptedUser.Status = OrganizationUserStatusType.Accepted;
|
||||
wrongOrgUser.OrganizationId = Guid.NewGuid();
|
||||
wrongOrgUser.Status = OrganizationUserStatusType.Invited;
|
||||
|
||||
var users = new List<OrganizationUser> { validUser1, validUser2, acceptedUser, wrongOrgUser };
|
||||
var userIds = users.Select(u => u.Id).ToList();
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>().GetManyAsync(userIds).Returns(users);
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
|
||||
|
||||
var result = (await sutProvider.Sut.BulkResendInvitesAsync(organization.Id, null, userIds)).ToList();
|
||||
|
||||
Assert.Equal(4, result.Count);
|
||||
Assert.Equal(2, result.Count(r => string.IsNullOrEmpty(r.Item2)));
|
||||
Assert.Equal(2, result.Count(r => r.Item2 == "User invalid."));
|
||||
|
||||
await sutProvider.GetDependency<ISendOrganizationInvitesCommand>()
|
||||
.Received(1)
|
||||
.SendInvitesAsync(Arg.Is<SendInvitesRequest>(req =>
|
||||
req.Organization == organization &&
|
||||
req.Users.Length == 2 &&
|
||||
req.InitOrganization == false));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task BulkResendInvitesAsync_AllInvalidUsers_DoesNotSendInvites(
|
||||
Organization organization,
|
||||
List<OrganizationUser> organizationUsers,
|
||||
SutProvider<BulkResendOrganizationInvitesCommand> sutProvider)
|
||||
{
|
||||
foreach (var user in organizationUsers)
|
||||
{
|
||||
user.OrganizationId = organization.Id;
|
||||
user.Status = OrganizationUserStatusType.Confirmed;
|
||||
}
|
||||
|
||||
var userIds = organizationUsers.Select(u => u.Id).ToList();
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>().GetManyAsync(userIds).Returns(organizationUsers);
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
|
||||
|
||||
var result = (await sutProvider.Sut.BulkResendInvitesAsync(organization.Id, null, userIds)).ToList();
|
||||
|
||||
Assert.Equal(organizationUsers.Count, result.Count);
|
||||
Assert.All(result, r => Assert.Equal("User invalid.", r.Item2));
|
||||
await sutProvider.GetDependency<ISendOrganizationInvitesCommand>().DidNotReceive()
|
||||
.SendInvitesAsync(Arg.Any<SendInvitesRequest>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task BulkResendInvitesAsync_OrganizationNotFound_ThrowsNotFoundException(
|
||||
Guid organizationId,
|
||||
List<Guid> userIds,
|
||||
List<OrganizationUser> organizationUsers,
|
||||
SutProvider<BulkResendOrganizationInvitesCommand> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>().GetManyAsync(userIds).Returns(organizationUsers);
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns((Organization?)null);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() =>
|
||||
sutProvider.Sut.BulkResendInvitesAsync(organizationId, null, userIds));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task BulkResendInvitesAsync_EmptyUserList_ReturnsEmpty(
|
||||
Organization organization,
|
||||
SutProvider<BulkResendOrganizationInvitesCommand> sutProvider)
|
||||
{
|
||||
var emptyUserIds = new List<Guid>();
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>().GetManyAsync(emptyUserIds).Returns(new List<OrganizationUser>());
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
|
||||
|
||||
var result = await sutProvider.Sut.BulkResendInvitesAsync(organization.Id, null, emptyUserIds);
|
||||
|
||||
Assert.Empty(result);
|
||||
await sutProvider.GetDependency<ISendOrganizationInvitesCommand>().DidNotReceive()
|
||||
.SendInvitesAsync(Arg.Any<SendInvitesRequest>());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
using Bit.Core.AdminConsole.Models.Data;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;
|
||||
using Bit.Core.AdminConsole.Utilities.v2.Validation;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Test.AutoFixture.OrganizationUserFixtures;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class RevokeOrganizationUserCommandTests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task RevokeUsersAsync_WithValidUsers_RevokesUsersAndLogsEvents(
|
||||
SutProvider<RevokeOrganizationUserCommand> sutProvider,
|
||||
Guid organizationId,
|
||||
Guid actingUserId,
|
||||
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser1,
|
||||
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser2)
|
||||
{
|
||||
// Arrange
|
||||
orgUser1.OrganizationId = orgUser2.OrganizationId = organizationId;
|
||||
orgUser1.UserId = Guid.NewGuid();
|
||||
orgUser2.UserId = Guid.NewGuid();
|
||||
|
||||
var actingUser = CreateActingUser(actingUserId, false, null);
|
||||
var request = new RevokeOrganizationUsersRequest(
|
||||
organizationId,
|
||||
[orgUser1.Id, orgUser2.Id],
|
||||
actingUser);
|
||||
|
||||
SetupRepositoryMocks(sutProvider, [orgUser1, orgUser2]);
|
||||
SetupValidatorMock(sutProvider, [
|
||||
ValidationResultHelpers.Valid(orgUser1),
|
||||
ValidationResultHelpers.Valid(orgUser2)
|
||||
]);
|
||||
|
||||
// Act
|
||||
var results = (await sutProvider.Sut.RevokeUsersAsync(request)).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, results.Count);
|
||||
Assert.All(results, r => Assert.True(r.Result.IsSuccess));
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.Received(1)
|
||||
.RevokeManyByIdAsync(Arg.Is<IEnumerable<Guid>>(ids =>
|
||||
ids.Contains(orgUser1.Id) && ids.Contains(orgUser2.Id)));
|
||||
|
||||
await sutProvider.GetDependency<IEventService>()
|
||||
.Received(1)
|
||||
.LogOrganizationUserEventsAsync(Arg.Is<IEnumerable<(OrganizationUser, EventType, DateTime?)>>(
|
||||
events => events.Count() == 2));
|
||||
|
||||
await sutProvider.GetDependency<IPushNotificationService>()
|
||||
.Received(1)
|
||||
.PushSyncOrgKeysAsync(orgUser1.UserId!.Value);
|
||||
|
||||
await sutProvider.GetDependency<IPushNotificationService>()
|
||||
.Received(1)
|
||||
.PushSyncOrgKeysAsync(orgUser2.UserId!.Value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task RevokeUsersAsync_WithSystemUser_LogsEventsWithSystemUserType(
|
||||
SutProvider<RevokeOrganizationUserCommand> sutProvider,
|
||||
Guid organizationId,
|
||||
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser)
|
||||
{
|
||||
// Arrange
|
||||
orgUser.OrganizationId = organizationId;
|
||||
orgUser.UserId = Guid.NewGuid();
|
||||
|
||||
var actingUser = CreateActingUser(null, false, EventSystemUser.SCIM);
|
||||
|
||||
var request = new RevokeOrganizationUsersRequest(
|
||||
organizationId,
|
||||
[orgUser.Id],
|
||||
actingUser);
|
||||
|
||||
SetupRepositoryMocks(sutProvider, [orgUser]);
|
||||
SetupValidatorMock(sutProvider, [ValidationResultHelpers.Valid(orgUser)]);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.RevokeUsersAsync(request);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IEventService>()
|
||||
.Received(1)
|
||||
.LogOrganizationUserEventsAsync(Arg.Is<IEnumerable<(OrganizationUser, EventType, EventSystemUser, DateTime?)>>(
|
||||
events => events.All(e => e.Item3 == EventSystemUser.SCIM)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task RevokeUsersAsync_WithValidationErrors_ReturnsErrorResults(
|
||||
SutProvider<RevokeOrganizationUserCommand> sutProvider,
|
||||
Guid organizationId,
|
||||
Guid actingUserId,
|
||||
[OrganizationUser(OrganizationUserStatusType.Revoked, OrganizationUserType.User)] OrganizationUser orgUser1,
|
||||
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser2)
|
||||
{
|
||||
// Arrange
|
||||
orgUser1.OrganizationId = orgUser2.OrganizationId = organizationId;
|
||||
|
||||
var actingUser = CreateActingUser(actingUserId, false, null);
|
||||
|
||||
var request = new RevokeOrganizationUsersRequest(
|
||||
organizationId,
|
||||
[orgUser1.Id, orgUser2.Id],
|
||||
actingUser);
|
||||
|
||||
SetupRepositoryMocks(sutProvider, [orgUser1, orgUser2]);
|
||||
SetupValidatorMock(sutProvider, [
|
||||
ValidationResultHelpers.Invalid(orgUser1, new UserAlreadyRevoked()),
|
||||
ValidationResultHelpers.Valid(orgUser2)
|
||||
]);
|
||||
|
||||
// Act
|
||||
var results = (await sutProvider.Sut.RevokeUsersAsync(request)).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, results.Count);
|
||||
var result1 = results.Single(r => r.Id == orgUser1.Id);
|
||||
var result2 = results.Single(r => r.Id == orgUser2.Id);
|
||||
|
||||
Assert.True(result1.Result.IsError);
|
||||
Assert.True(result2.Result.IsSuccess);
|
||||
|
||||
// Only the valid user should be revoked
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.Received(1)
|
||||
.RevokeManyByIdAsync(Arg.Is<IEnumerable<Guid>>(ids =>
|
||||
ids.Count() == 1 && ids.Contains(orgUser2.Id)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task RevokeUsersAsync_WhenPushNotificationFails_ContinuesProcessing(
|
||||
SutProvider<RevokeOrganizationUserCommand> sutProvider,
|
||||
Guid organizationId,
|
||||
Guid actingUserId,
|
||||
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser)
|
||||
{
|
||||
// Arrange
|
||||
orgUser.OrganizationId = organizationId;
|
||||
orgUser.UserId = Guid.NewGuid();
|
||||
|
||||
var actingUser = CreateActingUser(actingUserId, false, null);
|
||||
|
||||
var request = new RevokeOrganizationUsersRequest(
|
||||
organizationId,
|
||||
[orgUser.Id],
|
||||
actingUser);
|
||||
|
||||
SetupRepositoryMocks(sutProvider, [orgUser]);
|
||||
SetupValidatorMock(sutProvider, [ValidationResultHelpers.Valid(orgUser)]);
|
||||
|
||||
sutProvider.GetDependency<IPushNotificationService>()
|
||||
.PushSyncOrgKeysAsync(orgUser.UserId!.Value)
|
||||
.Returns(Task.FromException(new Exception("Push notification failed")));
|
||||
|
||||
// Act
|
||||
var results = (await sutProvider.Sut.RevokeUsersAsync(request)).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.True(results[0].Result.IsSuccess);
|
||||
|
||||
// Should log warning but continue
|
||||
sutProvider.GetDependency<ILogger<RevokeOrganizationUserCommand>>()
|
||||
.Received()
|
||||
.Log(
|
||||
LogLevel.Warning,
|
||||
Arg.Any<EventId>(),
|
||||
Arg.Any<object>(),
|
||||
Arg.Any<Exception>(),
|
||||
Arg.Any<Func<object, Exception?, string>>());
|
||||
}
|
||||
|
||||
private static IActingUser CreateActingUser(Guid? userId, bool isOwnerOrProvider, EventSystemUser? systemUserType) =>
|
||||
(userId, systemUserType) switch
|
||||
{
|
||||
({ } id, _) => new StandardUser(id, isOwnerOrProvider),
|
||||
(null, { } type) => new SystemUser(type)
|
||||
};
|
||||
|
||||
private static void SetupRepositoryMocks(
|
||||
SutProvider<RevokeOrganizationUserCommand> sutProvider,
|
||||
ICollection<OrganizationUser> organizationUsers)
|
||||
{
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns(organizationUsers);
|
||||
}
|
||||
|
||||
private static void SetupValidatorMock(
|
||||
SutProvider<RevokeOrganizationUserCommand> sutProvider,
|
||||
ICollection<ValidationResult<OrganizationUser>> validationResults)
|
||||
{
|
||||
sutProvider.GetDependency<IRevokeOrganizationUserValidator>()
|
||||
.ValidateAsync(Arg.Any<RevokeOrganizationUsersValidationRequest>())
|
||||
.Returns(validationResults);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
using Bit.Core.AdminConsole.Models.Data;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Test.AutoFixture.OrganizationUserFixtures;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class RevokeOrganizationUsersValidatorTests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ValidateAsync_WithValidUsers_ReturnsSuccess(
|
||||
SutProvider<RevokeOrganizationUsersValidator> sutProvider,
|
||||
Guid organizationId,
|
||||
Guid actingUserId,
|
||||
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser1,
|
||||
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser2)
|
||||
{
|
||||
// Arrange
|
||||
orgUser1.OrganizationId = orgUser2.OrganizationId = organizationId;
|
||||
orgUser1.UserId = Guid.NewGuid();
|
||||
orgUser2.UserId = Guid.NewGuid();
|
||||
|
||||
var actingUser = CreateActingUser(actingUserId, false, null);
|
||||
var request = CreateValidationRequest(
|
||||
organizationId,
|
||||
[orgUser1, orgUser2],
|
||||
actingUser);
|
||||
|
||||
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
|
||||
.HasConfirmedOwnersExceptAsync(organizationId, Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
var results = (await sutProvider.Sut.ValidateAsync(request)).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, results.Count);
|
||||
Assert.All(results, r => Assert.True(r.IsValid));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ValidateAsync_WithRevokedUser_ReturnsErrorForThatUser(
|
||||
SutProvider<RevokeOrganizationUsersValidator> sutProvider,
|
||||
Guid organizationId,
|
||||
Guid actingUserId,
|
||||
[OrganizationUser(OrganizationUserStatusType.Revoked, OrganizationUserType.User)] OrganizationUser revokedUser)
|
||||
{
|
||||
// Arrange
|
||||
revokedUser.OrganizationId = organizationId;
|
||||
revokedUser.UserId = Guid.NewGuid();
|
||||
|
||||
var actingUser = CreateActingUser(actingUserId, false, null);
|
||||
var request = CreateValidationRequest(
|
||||
organizationId,
|
||||
[revokedUser],
|
||||
actingUser);
|
||||
|
||||
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
|
||||
.HasConfirmedOwnersExceptAsync(organizationId, Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
var results = (await sutProvider.Sut.ValidateAsync(request)).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.True(results.First().IsError);
|
||||
Assert.IsType<UserAlreadyRevoked>(results.First().AsError);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ValidateAsync_WhenRevokingSelf_ReturnsErrorForThatUser(
|
||||
SutProvider<RevokeOrganizationUsersValidator> sutProvider,
|
||||
Guid organizationId,
|
||||
Guid actingUserId,
|
||||
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser)
|
||||
{
|
||||
// Arrange
|
||||
orgUser.OrganizationId = organizationId;
|
||||
orgUser.UserId = actingUserId;
|
||||
|
||||
var actingUser = CreateActingUser(actingUserId, false, null);
|
||||
var request = CreateValidationRequest(
|
||||
organizationId,
|
||||
[orgUser],
|
||||
actingUser);
|
||||
|
||||
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
|
||||
.HasConfirmedOwnersExceptAsync(organizationId, Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
var results = (await sutProvider.Sut.ValidateAsync(request)).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.True(results.First().IsError);
|
||||
Assert.IsType<CannotRevokeYourself>(results.First().AsError);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ValidateAsync_WhenNonOwnerRevokesOwner_ReturnsErrorForThatUser(
|
||||
SutProvider<RevokeOrganizationUsersValidator> sutProvider,
|
||||
Guid organizationId,
|
||||
Guid actingUserId,
|
||||
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser ownerUser)
|
||||
{
|
||||
// Arrange
|
||||
ownerUser.OrganizationId = organizationId;
|
||||
ownerUser.UserId = Guid.NewGuid();
|
||||
|
||||
var actingUser = CreateActingUser(actingUserId, false, null);
|
||||
var request = CreateValidationRequest(
|
||||
organizationId,
|
||||
[ownerUser],
|
||||
actingUser);
|
||||
|
||||
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
|
||||
.HasConfirmedOwnersExceptAsync(organizationId, Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
var results = (await sutProvider.Sut.ValidateAsync(request)).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.True(results.First().IsError);
|
||||
Assert.IsType<OnlyOwnersCanRevokeOwners>(results.First().AsError);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ValidateAsync_WhenOwnerRevokesOwner_ReturnsSuccess(
|
||||
SutProvider<RevokeOrganizationUsersValidator> sutProvider,
|
||||
Guid organizationId,
|
||||
Guid actingUserId,
|
||||
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser ownerUser)
|
||||
{
|
||||
// Arrange
|
||||
ownerUser.OrganizationId = organizationId;
|
||||
ownerUser.UserId = Guid.NewGuid();
|
||||
|
||||
var actingUser = CreateActingUser(actingUserId, true, null);
|
||||
var request = CreateValidationRequest(
|
||||
organizationId,
|
||||
[ownerUser],
|
||||
actingUser);
|
||||
|
||||
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
|
||||
.HasConfirmedOwnersExceptAsync(organizationId, Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
var results = (await sutProvider.Sut.ValidateAsync(request)).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.True(results.First().IsValid);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ValidateAsync_WithMultipleUsers_SomeValid_ReturnsMixedResults(
|
||||
SutProvider<RevokeOrganizationUsersValidator> sutProvider,
|
||||
Guid organizationId,
|
||||
Guid actingUserId,
|
||||
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser validUser,
|
||||
[OrganizationUser(OrganizationUserStatusType.Revoked, OrganizationUserType.User)] OrganizationUser revokedUser)
|
||||
{
|
||||
// Arrange
|
||||
validUser.OrganizationId = revokedUser.OrganizationId = organizationId;
|
||||
validUser.UserId = Guid.NewGuid();
|
||||
revokedUser.UserId = Guid.NewGuid();
|
||||
|
||||
var actingUser = CreateActingUser(actingUserId, false, null);
|
||||
var request = CreateValidationRequest(
|
||||
organizationId,
|
||||
[validUser, revokedUser],
|
||||
actingUser);
|
||||
|
||||
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
|
||||
.HasConfirmedOwnersExceptAsync(organizationId, Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
var results = (await sutProvider.Sut.ValidateAsync(request)).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, results.Count);
|
||||
|
||||
var validResult = results.Single(r => r.Request.Id == validUser.Id);
|
||||
var errorResult = results.Single(r => r.Request.Id == revokedUser.Id);
|
||||
|
||||
Assert.True(validResult.IsValid);
|
||||
Assert.True(errorResult.IsError);
|
||||
Assert.IsType<UserAlreadyRevoked>(errorResult.AsError);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ValidateAsync_WithSystemUser_DoesNotRequireActingUserId(
|
||||
SutProvider<RevokeOrganizationUsersValidator> sutProvider,
|
||||
Guid organizationId,
|
||||
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser)
|
||||
{
|
||||
// Arrange
|
||||
orgUser.OrganizationId = organizationId;
|
||||
orgUser.UserId = Guid.NewGuid();
|
||||
|
||||
var actingUser = CreateActingUser(null, false, EventSystemUser.SCIM);
|
||||
var request = CreateValidationRequest(
|
||||
organizationId,
|
||||
[orgUser],
|
||||
actingUser);
|
||||
|
||||
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
|
||||
.HasConfirmedOwnersExceptAsync(organizationId, Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
var results = (await sutProvider.Sut.ValidateAsync(request)).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.True(results.First().IsValid);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ValidateAsync_WhenRevokingLastOwner_ReturnsErrorForThatUser(
|
||||
SutProvider<RevokeOrganizationUsersValidator> sutProvider,
|
||||
Guid organizationId,
|
||||
Guid actingUserId,
|
||||
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser lastOwner)
|
||||
{
|
||||
// Arrange
|
||||
lastOwner.OrganizationId = organizationId;
|
||||
lastOwner.UserId = Guid.NewGuid();
|
||||
|
||||
var actingUser = CreateActingUser(actingUserId, true, null); // Is an owner
|
||||
var request = CreateValidationRequest(
|
||||
organizationId,
|
||||
[lastOwner],
|
||||
actingUser);
|
||||
|
||||
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
|
||||
.HasConfirmedOwnersExceptAsync(organizationId, Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns(false);
|
||||
|
||||
// Act
|
||||
var results = (await sutProvider.Sut.ValidateAsync(request)).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.True(results.First().IsError);
|
||||
Assert.IsType<MustHaveConfirmedOwner>(results.First().AsError);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ValidateAsync_WithMultipleValidationErrors_ReturnsAllErrors(
|
||||
SutProvider<RevokeOrganizationUsersValidator> sutProvider,
|
||||
Guid organizationId,
|
||||
Guid actingUserId,
|
||||
[OrganizationUser(OrganizationUserStatusType.Revoked, OrganizationUserType.User)] OrganizationUser revokedUser,
|
||||
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser ownerUser)
|
||||
{
|
||||
// Arrange
|
||||
revokedUser.OrganizationId = ownerUser.OrganizationId = organizationId;
|
||||
revokedUser.UserId = Guid.NewGuid();
|
||||
ownerUser.UserId = Guid.NewGuid();
|
||||
|
||||
var actingUser = CreateActingUser(actingUserId, false, null); // Not an owner
|
||||
var request = CreateValidationRequest(
|
||||
organizationId,
|
||||
[revokedUser, ownerUser],
|
||||
actingUser);
|
||||
|
||||
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
|
||||
.HasConfirmedOwnersExceptAsync(organizationId, Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
var results = (await sutProvider.Sut.ValidateAsync(request)).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, results.Count);
|
||||
Assert.All(results, r => Assert.True(r.IsError));
|
||||
|
||||
Assert.Contains(results, r => r.AsError is UserAlreadyRevoked);
|
||||
Assert.Contains(results, r => r.AsError is OnlyOwnersCanRevokeOwners);
|
||||
}
|
||||
|
||||
private static IActingUser CreateActingUser(Guid? userId, bool isOwnerOrProvider, EventSystemUser? systemUserType) =>
|
||||
(userId, systemUserType) switch
|
||||
{
|
||||
({ } id, _) => new StandardUser(id, isOwnerOrProvider),
|
||||
(null, { } type) => new SystemUser(type)
|
||||
};
|
||||
|
||||
|
||||
private static RevokeOrganizationUsersValidationRequest CreateValidationRequest(
|
||||
Guid organizationId,
|
||||
ICollection<OrganizationUser> organizationUsers,
|
||||
IActingUser actingUser)
|
||||
{
|
||||
return new RevokeOrganizationUsersValidationRequest(
|
||||
organizationId,
|
||||
organizationUsers.Select(u => u.Id).ToList(),
|
||||
actingUser,
|
||||
organizationUsers
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,52 +21,23 @@ namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyValidat
|
||||
public class AutomaticUserConfirmationPolicyEventHandlerTests
|
||||
{
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_EnablingPolicy_SingleOrgNotEnabled_ReturnsError(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
public void RequiredPolicies_IncludesSingleOrg(
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
|
||||
.Returns((Policy?)null);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
|
||||
var requiredPolicies = sutProvider.Sut.RequiredPolicies;
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Single organization policy must be enabled", result, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_EnablingPolicy_SingleOrgPolicyDisabled_ReturnsError(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.SingleOrg, false)] Policy singleOrgPolicy,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
|
||||
.Returns(singleOrgPolicy);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Single organization policy must be enabled", result, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains(PolicyType.SingleOrg, requiredPolicies);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_EnablingPolicy_UsersNotCompliantWithSingleOrg_ReturnsError(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
|
||||
Guid nonCompliantUserId,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
var orgUser = new OrganizationUserUserDetails
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
@@ -85,10 +56,6 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
||||
Status = OrganizationUserStatusType.Confirmed
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
|
||||
.Returns(singleOrgPolicy);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([orgUser]);
|
||||
@@ -107,13 +74,10 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_EnablingPolicy_UserWithInvitedStatusInOtherOrg_ValidationPasses(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
|
||||
Guid userId,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
var orgUser = new OrganizationUserUserDetails
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
@@ -121,7 +85,6 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
||||
Type = OrganizationUserType.User,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
UserId = userId,
|
||||
Email = "test@email.com"
|
||||
};
|
||||
|
||||
var otherOrgUser = new OrganizationUser
|
||||
@@ -133,10 +96,6 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
||||
Email = orgUser.Email
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
|
||||
.Returns(singleOrgPolicy);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([orgUser]);
|
||||
@@ -146,7 +105,7 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
||||
.Returns([otherOrgUser]);
|
||||
|
||||
sutProvider.GetDependency<IProviderUserRepository>()
|
||||
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([]);
|
||||
|
||||
// Act
|
||||
@@ -159,30 +118,37 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_EnablingPolicy_ProviderUsersExist_ReturnsError(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
|
||||
Guid userId,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
var orgUser = new OrganizationUserUserDetails
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrganizationId = policyUpdate.OrganizationId,
|
||||
Type = OrganizationUserType.User,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
UserId = userId
|
||||
};
|
||||
|
||||
var providerUser = new ProviderUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProviderId = Guid.NewGuid(),
|
||||
UserId = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
Status = ProviderUserStatusType.Confirmed
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
|
||||
.Returns(singleOrgPolicy);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([orgUser]);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([]);
|
||||
|
||||
sutProvider.GetDependency<IProviderUserRepository>()
|
||||
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([providerUser]);
|
||||
|
||||
// Act
|
||||
@@ -196,26 +162,18 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_EnablingPolicy_AllValidationsPassed_ReturnsEmptyString(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
var orgUser = new OrganizationUserUserDetails
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrganizationId = policyUpdate.OrganizationId,
|
||||
Type = OrganizationUserType.User,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
UserId = Guid.NewGuid(),
|
||||
Email = "user@example.com"
|
||||
UserId = Guid.NewGuid()
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
|
||||
.Returns(singleOrgPolicy);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([orgUser]);
|
||||
@@ -225,7 +183,7 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
||||
.Returns([]);
|
||||
|
||||
sutProvider.GetDependency<IProviderUserRepository>()
|
||||
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([]);
|
||||
|
||||
// Act
|
||||
@@ -249,9 +207,10 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
||||
|
||||
// Assert
|
||||
Assert.True(string.IsNullOrEmpty(result));
|
||||
await sutProvider.GetDependency<IPolicyRepository>()
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.DidNotReceive()
|
||||
.GetByOrganizationIdTypeAsync(Arg.Any<Guid>(), Arg.Any<PolicyType>());
|
||||
.GetManyDetailsByOrganizationAsync(Arg.Any<Guid>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
@@ -268,21 +227,18 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
||||
|
||||
// Assert
|
||||
Assert.True(string.IsNullOrEmpty(result));
|
||||
await sutProvider.GetDependency<IPolicyRepository>()
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.DidNotReceive()
|
||||
.GetByOrganizationIdTypeAsync(Arg.Any<Guid>(), Arg.Any<PolicyType>());
|
||||
.GetManyDetailsByOrganizationAsync(Arg.Any<Guid>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_EnablingPolicy_IncludesOwnersAndAdmins_InComplianceCheck(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
|
||||
Guid nonCompliantOwnerId,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
var ownerUser = new OrganizationUserUserDetails
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
@@ -290,7 +246,6 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
||||
Type = OrganizationUserType.Owner,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
UserId = nonCompliantOwnerId,
|
||||
Email = "owner@example.com"
|
||||
};
|
||||
|
||||
var otherOrgUser = new OrganizationUser
|
||||
@@ -301,10 +256,6 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
||||
Status = OrganizationUserStatusType.Confirmed
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
|
||||
.Returns(singleOrgPolicy);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([ownerUser]);
|
||||
@@ -323,12 +274,9 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_EnablingPolicy_InvitedUsersExcluded_FromComplianceCheck(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
var invitedUser = new OrganizationUserUserDetails
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
@@ -339,16 +287,12 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
||||
Email = "invited@example.com"
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
|
||||
.Returns(singleOrgPolicy);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([invitedUser]);
|
||||
|
||||
sutProvider.GetDependency<IProviderUserRepository>()
|
||||
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([]);
|
||||
|
||||
// Act
|
||||
@@ -359,14 +303,11 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_EnablingPolicy_RevokedUsersExcluded_FromComplianceCheck(
|
||||
public async Task ValidateAsync_EnablingPolicy_RevokedUsersIncluded_InComplianceCheck(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
var revokedUser = new OrganizationUserUserDetails
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
@@ -374,38 +315,44 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
||||
Type = OrganizationUserType.User,
|
||||
Status = OrganizationUserStatusType.Revoked,
|
||||
UserId = Guid.NewGuid(),
|
||||
Email = "revoked@example.com"
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
|
||||
.Returns(singleOrgPolicy);
|
||||
var additionalOrgUser = new OrganizationUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrganizationId = Guid.NewGuid(),
|
||||
Type = OrganizationUserType.User,
|
||||
Status = OrganizationUserStatusType.Revoked,
|
||||
UserId = revokedUser.UserId,
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
var orgUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
|
||||
|
||||
orgUserRepository
|
||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([revokedUser]);
|
||||
|
||||
orgUserRepository.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([additionalOrgUser]);
|
||||
|
||||
sutProvider.GetDependency<IProviderUserRepository>()
|
||||
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([]);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
|
||||
|
||||
// Assert
|
||||
Assert.True(string.IsNullOrEmpty(result));
|
||||
Assert.Contains("compliant with the Single organization policy", result, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_EnablingPolicy_AcceptedUsersIncluded_InComplianceCheck(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
|
||||
Guid nonCompliantUserId,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
var acceptedUser = new OrganizationUserUserDetails
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
@@ -413,7 +360,6 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
||||
Type = OrganizationUserType.User,
|
||||
Status = OrganizationUserStatusType.Accepted,
|
||||
UserId = nonCompliantUserId,
|
||||
Email = "accepted@example.com"
|
||||
};
|
||||
|
||||
var otherOrgUser = new OrganizationUser
|
||||
@@ -424,10 +370,6 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
||||
Status = OrganizationUserStatusType.Confirmed
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
|
||||
.Returns(singleOrgPolicy);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([acceptedUser]);
|
||||
@@ -443,186 +385,22 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
||||
Assert.Contains("compliant with the Single organization policy", result, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_EnablingPolicy_EmptyOrganization_ReturnsEmptyString(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
|
||||
.Returns(singleOrgPolicy);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([]);
|
||||
|
||||
sutProvider.GetDependency<IProviderUserRepository>()
|
||||
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([]);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
|
||||
|
||||
// Assert
|
||||
Assert.True(string.IsNullOrEmpty(result));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_WithSavePolicyModel_CallsValidateWithPolicyUpdate(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate);
|
||||
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
|
||||
.Returns(singleOrgPolicy);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([]);
|
||||
|
||||
sutProvider.GetDependency<IProviderUserRepository>()
|
||||
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([]);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, null);
|
||||
|
||||
// Assert
|
||||
Assert.True(string.IsNullOrEmpty(result));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task OnSaveSideEffectsAsync_EnablingPolicy_SetsUseAutomaticUserConfirmationToTrue(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
Organization organization,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
organization.Id = policyUpdate.OrganizationId;
|
||||
organization.UseAutomaticUserConfirmation = false;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdAsync(policyUpdate.OrganizationId)
|
||||
.Returns(organization);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.Received(1)
|
||||
.UpsertAsync(Arg.Is<Organization>(o =>
|
||||
o.Id == organization.Id &&
|
||||
o.UseAutomaticUserConfirmation == true &&
|
||||
o.RevisionDate > DateTime.MinValue));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task OnSaveSideEffectsAsync_DisablingPolicy_SetsUseAutomaticUserConfirmationToFalse(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation, false)] PolicyUpdate policyUpdate,
|
||||
Organization organization,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
organization.Id = policyUpdate.OrganizationId;
|
||||
organization.UseAutomaticUserConfirmation = true;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdAsync(policyUpdate.OrganizationId)
|
||||
.Returns(organization);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.Received(1)
|
||||
.UpsertAsync(Arg.Is<Organization>(o =>
|
||||
o.Id == organization.Id &&
|
||||
o.UseAutomaticUserConfirmation == false &&
|
||||
o.RevisionDate > DateTime.MinValue));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task OnSaveSideEffectsAsync_OrganizationNotFound_DoesNotThrowException(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdAsync(policyUpdate.OrganizationId)
|
||||
.Returns((Organization?)null);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.DidNotReceive()
|
||||
.UpsertAsync(Arg.Any<Organization>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ExecutePreUpsertSideEffectAsync_CallsOnSaveSideEffectsAsync(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.AutomaticUserConfirmation)] Policy currentPolicy,
|
||||
Organization organization,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
organization.Id = policyUpdate.OrganizationId;
|
||||
currentPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdAsync(policyUpdate.OrganizationId)
|
||||
.Returns(organization);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.ExecutePreUpsertSideEffectAsync(savePolicyModel, currentPolicy);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.Received(1)
|
||||
.UpsertAsync(Arg.Is<Organization>(o =>
|
||||
o.Id == organization.Id &&
|
||||
o.UseAutomaticUserConfirmation == policyUpdate.Enabled));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task OnSaveSideEffectsAsync_UpdatesRevisionDate(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
Organization organization,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
organization.Id = policyUpdate.OrganizationId;
|
||||
var originalRevisionDate = DateTime.UtcNow.AddDays(-1);
|
||||
organization.RevisionDate = originalRevisionDate;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdAsync(policyUpdate.OrganizationId)
|
||||
.Returns(organization);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.Received(1)
|
||||
.UpsertAsync(Arg.Is<Organization>(o =>
|
||||
o.Id == organization.Id &&
|
||||
o.RevisionDate > originalRevisionDate));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Text.Json;
|
||||
#nullable enable
|
||||
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
@@ -8,6 +10,7 @@ using Bit.Core.Models.Data.Organizations;
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Bit.Test.Common.Helpers;
|
||||
@@ -36,12 +39,16 @@ public class EventIntegrationHandlerTests
|
||||
private SutProvider<EventIntegrationHandler<WebhookIntegrationConfigurationDetails>> GetSutProvider(
|
||||
List<OrganizationIntegrationConfigurationDetails> configurations)
|
||||
{
|
||||
var configurationCache = Substitute.For<IIntegrationConfigurationDetailsCache>();
|
||||
configurationCache.GetConfigurationDetails(Arg.Any<Guid>(),
|
||||
IntegrationType.Webhook, Arg.Any<EventType>()).Returns(configurations);
|
||||
var cache = Substitute.For<IFusionCache>();
|
||||
cache.GetOrSetAsync(
|
||||
key: Arg.Any<string>(),
|
||||
factory: Arg.Any<Func<object, CancellationToken, Task<List<OrganizationIntegrationConfigurationDetails>>>>(),
|
||||
options: Arg.Any<FusionCacheEntryOptions>(),
|
||||
tags: Arg.Any<IEnumerable<string>>()
|
||||
).Returns(configurations);
|
||||
|
||||
return new SutProvider<EventIntegrationHandler<WebhookIntegrationConfigurationDetails>>()
|
||||
.SetDependency(configurationCache)
|
||||
.SetDependency(cache)
|
||||
.SetDependency(_eventIntegrationPublisher)
|
||||
.SetDependency(IntegrationType.Webhook)
|
||||
.SetDependency(_logger)
|
||||
@@ -173,6 +180,37 @@ public class EventIntegrationHandlerTests
|
||||
Assert.Null(context.ActingUser);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task BuildContextAsync_ActingUserFactory_CallsOrganizationUserRepository(EventMessage eventMessage, OrganizationUserUserDetails actingUser)
|
||||
{
|
||||
var sutProvider = GetSutProvider(OneConfiguration(_templateWithActingUser));
|
||||
var cache = sutProvider.GetDependency<IFusionCache>();
|
||||
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
|
||||
|
||||
eventMessage.OrganizationId ??= Guid.NewGuid();
|
||||
eventMessage.ActingUserId ??= Guid.NewGuid();
|
||||
organizationUserRepository.GetDetailsByOrganizationIdUserIdAsync(
|
||||
eventMessage.OrganizationId.Value,
|
||||
eventMessage.ActingUserId.Value).Returns(actingUser);
|
||||
|
||||
// Capture the factory function passed to the cache
|
||||
Func<FusionCacheFactoryExecutionContext<OrganizationUserUserDetails?>, CancellationToken, Task<OrganizationUserUserDetails?>>? capturedFactory = null;
|
||||
cache.GetOrSetAsync(
|
||||
key: Arg.Any<string>(),
|
||||
factory: Arg.Do<Func<FusionCacheFactoryExecutionContext<OrganizationUserUserDetails?>, CancellationToken, Task<OrganizationUserUserDetails?>>>(f => capturedFactory = f)
|
||||
).Returns(actingUser);
|
||||
|
||||
await sutProvider.Sut.BuildContextAsync(eventMessage, _templateWithActingUser);
|
||||
|
||||
Assert.NotNull(capturedFactory);
|
||||
var result = await capturedFactory(null!, CancellationToken.None);
|
||||
|
||||
await organizationUserRepository.Received(1).GetDetailsByOrganizationIdUserIdAsync(
|
||||
eventMessage.OrganizationId.Value,
|
||||
eventMessage.ActingUserId.Value);
|
||||
Assert.Equal(actingUser, result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task BuildContextAsync_GroupIdPresent_UsesCache(EventMessage eventMessage, Group group)
|
||||
{
|
||||
@@ -211,6 +249,32 @@ public class EventIntegrationHandlerTests
|
||||
Assert.Null(context.Group);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task BuildContextAsync_GroupFactory_CallsGroupRepository(EventMessage eventMessage, Group group)
|
||||
{
|
||||
var sutProvider = GetSutProvider(OneConfiguration(_templateWithGroup));
|
||||
var cache = sutProvider.GetDependency<IFusionCache>();
|
||||
var groupRepository = sutProvider.GetDependency<IGroupRepository>();
|
||||
|
||||
eventMessage.GroupId ??= Guid.NewGuid();
|
||||
groupRepository.GetByIdAsync(eventMessage.GroupId.Value).Returns(group);
|
||||
|
||||
// Capture the factory function passed to the cache
|
||||
Func<FusionCacheFactoryExecutionContext<Group?>, CancellationToken, Task<Group?>>? capturedFactory = null;
|
||||
cache.GetOrSetAsync(
|
||||
key: Arg.Any<string>(),
|
||||
factory: Arg.Do<Func<FusionCacheFactoryExecutionContext<Group?>, CancellationToken, Task<Group?>>>(f => capturedFactory = f)
|
||||
).Returns(group);
|
||||
|
||||
await sutProvider.Sut.BuildContextAsync(eventMessage, _templateWithGroup);
|
||||
|
||||
Assert.NotNull(capturedFactory);
|
||||
var result = await capturedFactory(null!, CancellationToken.None);
|
||||
|
||||
await groupRepository.Received(1).GetByIdAsync(eventMessage.GroupId.Value);
|
||||
Assert.Equal(group, result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task BuildContextAsync_OrganizationIdPresent_UsesCache(EventMessage eventMessage, Organization organization)
|
||||
{
|
||||
@@ -250,6 +314,32 @@ public class EventIntegrationHandlerTests
|
||||
Assert.Null(context.Organization);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task BuildContextAsync_OrganizationFactory_CallsOrganizationRepository(EventMessage eventMessage, Organization organization)
|
||||
{
|
||||
var sutProvider = GetSutProvider(OneConfiguration(_templateWithOrganization));
|
||||
var cache = sutProvider.GetDependency<IFusionCache>();
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
|
||||
eventMessage.OrganizationId ??= Guid.NewGuid();
|
||||
organizationRepository.GetByIdAsync(eventMessage.OrganizationId.Value).Returns(organization);
|
||||
|
||||
// Capture the factory function passed to the cache
|
||||
Func<FusionCacheFactoryExecutionContext<Organization?>, CancellationToken, Task<Organization?>>? capturedFactory = null;
|
||||
cache.GetOrSetAsync(
|
||||
key: Arg.Any<string>(),
|
||||
factory: Arg.Do<Func<FusionCacheFactoryExecutionContext<Organization?>, CancellationToken, Task<Organization?>>>(f => capturedFactory = f)
|
||||
).Returns(organization);
|
||||
|
||||
await sutProvider.Sut.BuildContextAsync(eventMessage, _templateWithOrganization);
|
||||
|
||||
Assert.NotNull(capturedFactory);
|
||||
var result = await capturedFactory(null!, CancellationToken.None);
|
||||
|
||||
await organizationRepository.Received(1).GetByIdAsync(eventMessage.OrganizationId.Value);
|
||||
Assert.Equal(organization, result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task BuildContextAsync_UserIdPresent_UsesCache(EventMessage eventMessage, OrganizationUserUserDetails userDetails)
|
||||
{
|
||||
@@ -313,6 +403,38 @@ public class EventIntegrationHandlerTests
|
||||
Assert.Null(context.User);
|
||||
}
|
||||
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task BuildContextAsync_UserFactory_CallsOrganizationUserRepository(EventMessage eventMessage, OrganizationUserUserDetails userDetails)
|
||||
{
|
||||
var sutProvider = GetSutProvider(OneConfiguration(_templateWithUser));
|
||||
var cache = sutProvider.GetDependency<IFusionCache>();
|
||||
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
|
||||
|
||||
eventMessage.OrganizationId ??= Guid.NewGuid();
|
||||
eventMessage.UserId ??= Guid.NewGuid();
|
||||
organizationUserRepository.GetDetailsByOrganizationIdUserIdAsync(
|
||||
eventMessage.OrganizationId.Value,
|
||||
eventMessage.UserId.Value).Returns(userDetails);
|
||||
|
||||
// Capture the factory function passed to the cache
|
||||
Func<FusionCacheFactoryExecutionContext<OrganizationUserUserDetails?>, CancellationToken, Task<OrganizationUserUserDetails?>>? capturedFactory = null;
|
||||
cache.GetOrSetAsync(
|
||||
key: Arg.Any<string>(),
|
||||
factory: Arg.Do<Func<FusionCacheFactoryExecutionContext<OrganizationUserUserDetails?>, CancellationToken, Task<OrganizationUserUserDetails?>>>(f => capturedFactory = f)
|
||||
).Returns(userDetails);
|
||||
|
||||
await sutProvider.Sut.BuildContextAsync(eventMessage, _templateWithUser);
|
||||
|
||||
Assert.NotNull(capturedFactory);
|
||||
var result = await capturedFactory(null!, CancellationToken.None);
|
||||
|
||||
await organizationUserRepository.Received(1).GetDetailsByOrganizationIdUserIdAsync(
|
||||
eventMessage.OrganizationId.Value,
|
||||
eventMessage.UserId.Value);
|
||||
Assert.Equal(userDetails, result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task BuildContextAsync_NoSpecialTokens_DoesNotCallAnyCache(EventMessage eventMessage)
|
||||
{
|
||||
@@ -344,6 +466,12 @@ public class EventIntegrationHandlerTests
|
||||
public async Task HandleEventAsync_BaseTemplateNoConfigurations_DoesNothing(EventMessage eventMessage)
|
||||
{
|
||||
var sutProvider = GetSutProvider(NoConfigurations());
|
||||
var cache = sutProvider.GetDependency<IFusionCache>();
|
||||
cache.GetOrSetAsync<List<OrganizationIntegrationConfigurationDetails>>(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<Func<object, CancellationToken, Task<List<OrganizationIntegrationConfigurationDetails>>>>(),
|
||||
Arg.Any<FusionCacheEntryOptions>()
|
||||
).Returns(NoConfigurations());
|
||||
|
||||
await sutProvider.Sut.HandleEventAsync(eventMessage);
|
||||
Assert.Empty(_eventIntegrationPublisher.ReceivedCalls());
|
||||
@@ -362,8 +490,8 @@ public class EventIntegrationHandlerTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleEventAsync_BaseTemplateOneConfiguration_PublishesIntegrationMessage(EventMessage eventMessage)
|
||||
{
|
||||
var sutProvider = GetSutProvider(OneConfiguration(_templateBase));
|
||||
eventMessage.OrganizationId = _organizationId;
|
||||
var sutProvider = GetSutProvider(OneConfiguration(_templateBase));
|
||||
|
||||
await sutProvider.Sut.HandleEventAsync(eventMessage);
|
||||
|
||||
@@ -382,8 +510,8 @@ public class EventIntegrationHandlerTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleEventAsync_BaseTemplateTwoConfigurations_PublishesIntegrationMessages(EventMessage eventMessage)
|
||||
{
|
||||
var sutProvider = GetSutProvider(TwoConfigurations(_templateBase));
|
||||
eventMessage.OrganizationId = _organizationId;
|
||||
var sutProvider = GetSutProvider(TwoConfigurations(_templateBase));
|
||||
|
||||
await sutProvider.Sut.HandleEventAsync(eventMessage);
|
||||
|
||||
@@ -405,6 +533,7 @@ public class EventIntegrationHandlerTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleEventAsync_FilterReturnsFalse_DoesNothing(EventMessage eventMessage)
|
||||
{
|
||||
eventMessage.OrganizationId = _organizationId;
|
||||
var sutProvider = GetSutProvider(ValidFilterConfiguration());
|
||||
sutProvider.GetDependency<IIntegrationFilterService>().EvaluateFilterGroup(
|
||||
Arg.Any<IntegrationFilterGroup>(), Arg.Any<EventMessage>()).Returns(false);
|
||||
@@ -416,10 +545,10 @@ public class EventIntegrationHandlerTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleEventAsync_FilterReturnsTrue_PublishesIntegrationMessage(EventMessage eventMessage)
|
||||
{
|
||||
eventMessage.OrganizationId = _organizationId;
|
||||
var sutProvider = GetSutProvider(ValidFilterConfiguration());
|
||||
sutProvider.GetDependency<IIntegrationFilterService>().EvaluateFilterGroup(
|
||||
Arg.Any<IntegrationFilterGroup>(), Arg.Any<EventMessage>()).Returns(true);
|
||||
eventMessage.OrganizationId = _organizationId;
|
||||
|
||||
await sutProvider.Sut.HandleEventAsync(eventMessage);
|
||||
|
||||
@@ -435,6 +564,7 @@ public class EventIntegrationHandlerTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleEventAsync_InvalidFilter_LogsErrorDoesNothing(EventMessage eventMessage)
|
||||
{
|
||||
eventMessage.OrganizationId = _organizationId;
|
||||
var sutProvider = GetSutProvider(InvalidFilterConfiguration());
|
||||
|
||||
await sutProvider.Sut.HandleEventAsync(eventMessage);
|
||||
@@ -444,12 +574,13 @@ public class EventIntegrationHandlerTests
|
||||
Arg.Any<EventId>(),
|
||||
Arg.Any<object>(),
|
||||
Arg.Any<JsonException>(),
|
||||
Arg.Any<Func<object, Exception, string>>());
|
||||
Arg.Any<Func<object, Exception?, string>>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleManyEventsAsync_BaseTemplateNoConfigurations_DoesNothing(List<EventMessage> eventMessages)
|
||||
{
|
||||
eventMessages.ForEach(e => e.OrganizationId = _organizationId);
|
||||
var sutProvider = GetSutProvider(NoConfigurations());
|
||||
|
||||
await sutProvider.Sut.HandleManyEventsAsync(eventMessages);
|
||||
@@ -459,13 +590,14 @@ public class EventIntegrationHandlerTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleManyEventsAsync_BaseTemplateOneConfiguration_PublishesIntegrationMessages(List<EventMessage> eventMessages)
|
||||
{
|
||||
eventMessages.ForEach(e => e.OrganizationId = _organizationId);
|
||||
var sutProvider = GetSutProvider(OneConfiguration(_templateBase));
|
||||
|
||||
await sutProvider.Sut.HandleManyEventsAsync(eventMessages);
|
||||
|
||||
foreach (var eventMessage in eventMessages)
|
||||
{
|
||||
var expectedMessage = EventIntegrationHandlerTests.ExpectedMessage(
|
||||
var expectedMessage = ExpectedMessage(
|
||||
$"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}"
|
||||
);
|
||||
await _eventIntegrationPublisher.Received(1).PublishAsync(Arg.Is(
|
||||
@@ -477,13 +609,14 @@ public class EventIntegrationHandlerTests
|
||||
public async Task HandleManyEventsAsync_BaseTemplateTwoConfigurations_PublishesIntegrationMessages(
|
||||
List<EventMessage> eventMessages)
|
||||
{
|
||||
eventMessages.ForEach(e => e.OrganizationId = _organizationId);
|
||||
var sutProvider = GetSutProvider(TwoConfigurations(_templateBase));
|
||||
|
||||
await sutProvider.Sut.HandleManyEventsAsync(eventMessages);
|
||||
|
||||
foreach (var eventMessage in eventMessages)
|
||||
{
|
||||
var expectedMessage = EventIntegrationHandlerTests.ExpectedMessage(
|
||||
var expectedMessage = ExpectedMessage(
|
||||
$"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}"
|
||||
);
|
||||
await _eventIntegrationPublisher.Received(1).PublishAsync(Arg.Is(AssertHelper.AssertPropertyEqual(
|
||||
@@ -494,4 +627,84 @@ public class EventIntegrationHandlerTests
|
||||
expectedMessage, new[] { "MessageId", "OrganizationId" })));
|
||||
}
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleEventAsync_CapturedFactories_CallConfigurationRepository(EventMessage eventMessage)
|
||||
{
|
||||
eventMessage.OrganizationId = _organizationId;
|
||||
var sutProvider = GetSutProvider(NoConfigurations());
|
||||
var cache = sutProvider.GetDependency<IFusionCache>();
|
||||
var configurationRepository = sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>();
|
||||
|
||||
var configs = OneConfiguration(_templateBase);
|
||||
|
||||
configurationRepository.GetManyByEventTypeOrganizationIdIntegrationType(eventType: eventMessage.Type, organizationId: _organizationId, integrationType: IntegrationType.Webhook).Returns(configs);
|
||||
|
||||
// Capture the factory function - there will be 1 call that returns both specific and wildcard matches
|
||||
Func<FusionCacheFactoryExecutionContext<List<OrganizationIntegrationConfigurationDetails>>, CancellationToken, Task<List<OrganizationIntegrationConfigurationDetails>>>? capturedFactory = null;
|
||||
cache.GetOrSetAsync(
|
||||
key: Arg.Any<string>(),
|
||||
factory: Arg.Do<Func<FusionCacheFactoryExecutionContext<List<OrganizationIntegrationConfigurationDetails>>, CancellationToken, Task<List<OrganizationIntegrationConfigurationDetails>>>>(f
|
||||
=> capturedFactory = f),
|
||||
options: Arg.Any<FusionCacheEntryOptions>(),
|
||||
tags: Arg.Any<IEnumerable<string>>()
|
||||
).Returns(new List<OrganizationIntegrationConfigurationDetails>());
|
||||
|
||||
await sutProvider.Sut.HandleEventAsync(eventMessage);
|
||||
|
||||
// Verify factory was captured
|
||||
Assert.NotNull(capturedFactory);
|
||||
|
||||
// Execute the captured factory to trigger repository call
|
||||
await capturedFactory(null!, CancellationToken.None);
|
||||
|
||||
await configurationRepository.Received(1).GetManyByEventTypeOrganizationIdIntegrationType(eventType: eventMessage.Type, organizationId: _organizationId, integrationType: IntegrationType.Webhook);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleEventAsync_ConfigurationCacheOptions_SetsDurationToConstant(EventMessage eventMessage)
|
||||
{
|
||||
eventMessage.OrganizationId = _organizationId;
|
||||
var sutProvider = GetSutProvider(NoConfigurations());
|
||||
var cache = sutProvider.GetDependency<IFusionCache>();
|
||||
|
||||
FusionCacheEntryOptions? capturedOption = null;
|
||||
cache.GetOrSetAsync(
|
||||
key: Arg.Any<string>(),
|
||||
factory: Arg.Any<Func<FusionCacheFactoryExecutionContext<List<OrganizationIntegrationConfigurationDetails>>, CancellationToken, Task<List<OrganizationIntegrationConfigurationDetails>>>>(),
|
||||
options: Arg.Do<FusionCacheEntryOptions>(opt => capturedOption = opt),
|
||||
tags: Arg.Any<IEnumerable<string>?>()
|
||||
).Returns(new List<OrganizationIntegrationConfigurationDetails>());
|
||||
|
||||
await sutProvider.Sut.HandleEventAsync(eventMessage);
|
||||
|
||||
Assert.NotNull(capturedOption);
|
||||
Assert.Equal(EventIntegrationsCacheConstants.DurationForOrganizationIntegrationConfigurationDetails,
|
||||
capturedOption.Duration);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleEventAsync_ConfigurationCache_AddsOrganizationIntegrationTag(EventMessage eventMessage)
|
||||
{
|
||||
eventMessage.OrganizationId = _organizationId;
|
||||
var sutProvider = GetSutProvider(NoConfigurations());
|
||||
var cache = sutProvider.GetDependency<IFusionCache>();
|
||||
|
||||
IEnumerable<string>? capturedTags = null;
|
||||
cache.GetOrSetAsync(
|
||||
key: Arg.Any<string>(),
|
||||
factory: Arg.Any<Func<FusionCacheFactoryExecutionContext<List<OrganizationIntegrationConfigurationDetails>>, CancellationToken, Task<List<OrganizationIntegrationConfigurationDetails>>>>(),
|
||||
options: Arg.Any<FusionCacheEntryOptions>(),
|
||||
tags: Arg.Do<IEnumerable<string>>(t => capturedTags = t)
|
||||
).Returns(new List<OrganizationIntegrationConfigurationDetails>());
|
||||
|
||||
await sutProvider.Sut.HandleEventAsync(eventMessage);
|
||||
|
||||
var expectedTag = EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
|
||||
_organizationId,
|
||||
IntegrationType.Webhook
|
||||
);
|
||||
Assert.NotNull(capturedTags);
|
||||
Assert.Contains(expectedTag, capturedTags);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Text.Json;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data.Organizations;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Services;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class IntegrationConfigurationDetailsCacheServiceTests
|
||||
{
|
||||
private SutProvider<IntegrationConfigurationDetailsCacheService> GetSutProvider(
|
||||
List<OrganizationIntegrationConfigurationDetails> configurations)
|
||||
{
|
||||
var configurationRepository = Substitute.For<IOrganizationIntegrationConfigurationRepository>();
|
||||
configurationRepository.GetAllConfigurationDetailsAsync().Returns(configurations);
|
||||
|
||||
return new SutProvider<IntegrationConfigurationDetailsCacheService>()
|
||||
.SetDependency(configurationRepository)
|
||||
.Create();
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetConfigurationDetails_SpecificKeyExists_ReturnsExpectedList(OrganizationIntegrationConfigurationDetails config)
|
||||
{
|
||||
config.EventType = EventType.Cipher_Created;
|
||||
var sutProvider = GetSutProvider([config]);
|
||||
await sutProvider.Sut.RefreshAsync();
|
||||
var result = sutProvider.Sut.GetConfigurationDetails(
|
||||
config.OrganizationId,
|
||||
config.IntegrationType,
|
||||
EventType.Cipher_Created);
|
||||
Assert.Single(result);
|
||||
Assert.Same(config, result[0]);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetConfigurationDetails_AllEventsKeyExists_ReturnsExpectedList(OrganizationIntegrationConfigurationDetails config)
|
||||
{
|
||||
config.EventType = null;
|
||||
var sutProvider = GetSutProvider([config]);
|
||||
await sutProvider.Sut.RefreshAsync();
|
||||
var result = sutProvider.Sut.GetConfigurationDetails(
|
||||
config.OrganizationId,
|
||||
config.IntegrationType,
|
||||
EventType.Cipher_Created);
|
||||
Assert.Single(result);
|
||||
Assert.Same(config, result[0]);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetConfigurationDetails_BothSpecificAndAllEventsKeyExists_ReturnsExpectedList(
|
||||
OrganizationIntegrationConfigurationDetails specificConfig,
|
||||
OrganizationIntegrationConfigurationDetails allKeysConfig
|
||||
)
|
||||
{
|
||||
specificConfig.EventType = EventType.Cipher_Created;
|
||||
allKeysConfig.EventType = null;
|
||||
allKeysConfig.OrganizationId = specificConfig.OrganizationId;
|
||||
allKeysConfig.IntegrationType = specificConfig.IntegrationType;
|
||||
|
||||
var sutProvider = GetSutProvider([specificConfig, allKeysConfig]);
|
||||
await sutProvider.Sut.RefreshAsync();
|
||||
var result = sutProvider.Sut.GetConfigurationDetails(
|
||||
specificConfig.OrganizationId,
|
||||
specificConfig.IntegrationType,
|
||||
EventType.Cipher_Created);
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Contains(result, r => r.Template == specificConfig.Template);
|
||||
Assert.Contains(result, r => r.Template == allKeysConfig.Template);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetConfigurationDetails_KeyMissing_ReturnsEmptyList(OrganizationIntegrationConfigurationDetails config)
|
||||
{
|
||||
var sutProvider = GetSutProvider([config]);
|
||||
await sutProvider.Sut.RefreshAsync();
|
||||
var result = sutProvider.Sut.GetConfigurationDetails(
|
||||
Guid.NewGuid(),
|
||||
config.IntegrationType,
|
||||
config.EventType ?? EventType.Cipher_Created);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetConfigurationDetails_ReturnsCachedValue_EvenIfRepositoryChanges(OrganizationIntegrationConfigurationDetails config)
|
||||
{
|
||||
var sutProvider = GetSutProvider([config]);
|
||||
await sutProvider.Sut.RefreshAsync();
|
||||
|
||||
var newConfig = JsonSerializer.Deserialize<OrganizationIntegrationConfigurationDetails>(JsonSerializer.Serialize(config));
|
||||
Assert.NotNull(newConfig);
|
||||
newConfig.Template = "Changed";
|
||||
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>().GetAllConfigurationDetailsAsync()
|
||||
.Returns([newConfig]);
|
||||
|
||||
var result = sutProvider.Sut.GetConfigurationDetails(
|
||||
config.OrganizationId,
|
||||
config.IntegrationType,
|
||||
config.EventType ?? EventType.Cipher_Created);
|
||||
Assert.Single(result);
|
||||
Assert.NotEqual("Changed", result[0].Template); // should not yet pick up change from repository
|
||||
|
||||
await sutProvider.Sut.RefreshAsync(); // Pick up changes
|
||||
|
||||
result = sutProvider.Sut.GetConfigurationDetails(
|
||||
config.OrganizationId,
|
||||
config.IntegrationType,
|
||||
config.EventType ?? EventType.Cipher_Created);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("Changed", result[0].Template); // Should have the new value
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RefreshAsync_GroupsByCompositeKey(OrganizationIntegrationConfigurationDetails config1)
|
||||
{
|
||||
var config2 = JsonSerializer.Deserialize<OrganizationIntegrationConfigurationDetails>(
|
||||
JsonSerializer.Serialize(config1))!;
|
||||
config2.Template = "Another";
|
||||
|
||||
var sutProvider = GetSutProvider([config1, config2]);
|
||||
await sutProvider.Sut.RefreshAsync();
|
||||
|
||||
var results = sutProvider.Sut.GetConfigurationDetails(
|
||||
config1.OrganizationId,
|
||||
config1.IntegrationType,
|
||||
config1.EventType ?? EventType.Cipher_Created);
|
||||
|
||||
Assert.Equal(2, results.Count);
|
||||
Assert.Contains(results, r => r.Template == config1.Template);
|
||||
Assert.Contains(results, r => r.Template == config2.Template);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RefreshAsync_LogsInformationOnSuccess(OrganizationIntegrationConfigurationDetails config)
|
||||
{
|
||||
var sutProvider = GetSutProvider([config]);
|
||||
await sutProvider.Sut.RefreshAsync();
|
||||
|
||||
sutProvider.GetDependency<ILogger<IntegrationConfigurationDetailsCacheService>>().Received().Log(
|
||||
LogLevel.Information,
|
||||
Arg.Any<EventId>(),
|
||||
Arg.Is<object>(o => o.ToString()!.Contains("Refreshed successfully")),
|
||||
null,
|
||||
Arg.Any<Func<object, Exception?, string>>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshAsync_OnException_LogsError()
|
||||
{
|
||||
var sutProvider = GetSutProvider([]);
|
||||
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>().GetAllConfigurationDetailsAsync()
|
||||
.Throws(new Exception("Database failure"));
|
||||
await sutProvider.Sut.RefreshAsync();
|
||||
|
||||
sutProvider.GetDependency<ILogger<IntegrationConfigurationDetailsCacheService>>().Received(1).Log(
|
||||
LogLevel.Error,
|
||||
Arg.Any<EventId>(),
|
||||
Arg.Is<object>(o => o.ToString()!.Contains("Refresh failed")),
|
||||
Arg.Any<Exception>(),
|
||||
Arg.Any<Func<object, Exception?, string>>());
|
||||
}
|
||||
}
|
||||
@@ -1017,6 +1017,7 @@ public class RegisterUserCommandTests
|
||||
[Theory]
|
||||
[BitAutoData(PlanType.FamiliesAnnually)]
|
||||
[BitAutoData(PlanType.FamiliesAnnually2019)]
|
||||
[BitAutoData(PlanType.FamiliesAnnually2025)]
|
||||
[BitAutoData(PlanType.Free)]
|
||||
public async Task SendWelcomeEmail_FamilyOrg_SendsFamilyWelcomeEmail(
|
||||
PlanType planType,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Xunit;
|
||||
|
||||
@@ -11,8 +12,12 @@ public class EventIntegrationsCacheConstantsTests
|
||||
{
|
||||
var expected = $"Group:{groupId:N}";
|
||||
var key = EventIntegrationsCacheConstants.BuildCacheKeyForGroup(groupId);
|
||||
var keyWithDifferentGroup = EventIntegrationsCacheConstants.BuildCacheKeyForGroup(Guid.NewGuid());
|
||||
var keyWithSameGroup = EventIntegrationsCacheConstants.BuildCacheKeyForGroup(groupId);
|
||||
|
||||
Assert.Equal(expected, key);
|
||||
Assert.NotEqual(key, keyWithDifferentGroup);
|
||||
Assert.Equal(key, keyWithSameGroup);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
@@ -20,8 +25,69 @@ public class EventIntegrationsCacheConstantsTests
|
||||
{
|
||||
var expected = $"Organization:{orgId:N}";
|
||||
var key = EventIntegrationsCacheConstants.BuildCacheKeyForOrganization(orgId);
|
||||
var keyWithDifferentOrg = EventIntegrationsCacheConstants.BuildCacheKeyForOrganization(Guid.NewGuid());
|
||||
var keyWithSameOrg = EventIntegrationsCacheConstants.BuildCacheKeyForOrganization(orgId);
|
||||
|
||||
Assert.Equal(expected, key);
|
||||
Assert.NotEqual(key, keyWithDifferentOrg);
|
||||
Assert.Equal(key, keyWithSameOrg);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public void BuildCacheKeyForOrganizationIntegrationConfigurationDetails_ReturnsExpectedKey(Guid orgId)
|
||||
{
|
||||
var integrationType = IntegrationType.Hec;
|
||||
|
||||
var expectedWithEvent = $"OrganizationIntegrationConfigurationDetails:{orgId:N}:Hec:User_LoggedIn";
|
||||
var keyWithEvent = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails(
|
||||
orgId, integrationType, EventType.User_LoggedIn);
|
||||
var keyWithDifferentEvent = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails(
|
||||
orgId, integrationType, EventType.Cipher_Created);
|
||||
var keyWithDifferentIntegration = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails(
|
||||
orgId, IntegrationType.Webhook, EventType.User_LoggedIn);
|
||||
var keyWithDifferentOrganization = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails(
|
||||
Guid.NewGuid(), integrationType, EventType.User_LoggedIn);
|
||||
var keyWithSameDetails = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails(
|
||||
orgId, integrationType, EventType.User_LoggedIn);
|
||||
|
||||
Assert.Equal(expectedWithEvent, keyWithEvent);
|
||||
Assert.NotEqual(keyWithEvent, keyWithDifferentEvent);
|
||||
Assert.NotEqual(keyWithEvent, keyWithDifferentIntegration);
|
||||
Assert.NotEqual(keyWithEvent, keyWithDifferentOrganization);
|
||||
Assert.Equal(keyWithEvent, keyWithSameDetails);
|
||||
|
||||
var expectedWithNullEvent = $"OrganizationIntegrationConfigurationDetails:{orgId:N}:Hec:";
|
||||
var keyWithNullEvent = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails(
|
||||
orgId, integrationType, null);
|
||||
var keyWithNullEventDifferentIntegration = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails(
|
||||
orgId, IntegrationType.Webhook, null);
|
||||
var keyWithNullEventDifferentOrganization = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails(
|
||||
Guid.NewGuid(), integrationType, null);
|
||||
|
||||
Assert.Equal(expectedWithNullEvent, keyWithNullEvent);
|
||||
Assert.NotEqual(keyWithEvent, keyWithNullEvent);
|
||||
Assert.NotEqual(keyWithNullEvent, keyWithDifferentEvent);
|
||||
Assert.NotEqual(keyWithNullEvent, keyWithNullEventDifferentIntegration);
|
||||
Assert.NotEqual(keyWithNullEvent, keyWithNullEventDifferentOrganization);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public void BuildCacheTagForOrganizationIntegration_ReturnsExpectedKey(Guid orgId)
|
||||
{
|
||||
var expected = $"OrganizationIntegration:{orgId:N}:Hec";
|
||||
var tag = EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
|
||||
orgId, IntegrationType.Hec);
|
||||
var tagWithDifferentOrganization = EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
|
||||
Guid.NewGuid(), IntegrationType.Hec);
|
||||
var tagWithDifferentIntegrationType = EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
|
||||
orgId, IntegrationType.Webhook);
|
||||
var tagWithSameDetails = EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
|
||||
orgId, IntegrationType.Hec);
|
||||
|
||||
Assert.Equal(expected, tag);
|
||||
Assert.NotEqual(tag, tagWithDifferentOrganization);
|
||||
Assert.NotEqual(tag, tagWithDifferentIntegrationType);
|
||||
Assert.Equal(tag, tagWithSameDetails);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
@@ -29,8 +95,14 @@ public class EventIntegrationsCacheConstantsTests
|
||||
{
|
||||
var expected = $"OrganizationUserUserDetails:{orgId:N}:{userId:N}";
|
||||
var key = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationUser(orgId, userId);
|
||||
var keyWithDifferentOrg = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationUser(Guid.NewGuid(), userId);
|
||||
var keyWithDifferentUser = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationUser(orgId, Guid.NewGuid());
|
||||
var keyWithSameDetails = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationUser(orgId, userId);
|
||||
|
||||
Assert.Equal(expected, key);
|
||||
Assert.NotEqual(key, keyWithDifferentOrg);
|
||||
Assert.NotEqual(key, keyWithDifferentUser);
|
||||
Assert.Equal(key, keyWithSameDetails);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -38,4 +110,13 @@ public class EventIntegrationsCacheConstantsTests
|
||||
{
|
||||
Assert.Equal("EventIntegrations", EventIntegrationsCacheConstants.CacheName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DurationForOrganizationIntegrationConfigurationDetails_ReturnsExpected()
|
||||
{
|
||||
Assert.Equal(
|
||||
TimeSpan.FromDays(1),
|
||||
EventIntegrationsCacheConstants.DurationForOrganizationIntegrationConfigurationDetails
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ using NSubstitute;
|
||||
using StackExchange.Redis;
|
||||
using Xunit;
|
||||
using ZiggyCreatures.Caching.Fusion;
|
||||
using ZiggyCreatures.Caching.Fusion.Backplane;
|
||||
|
||||
namespace Bit.Core.Test.Utilities;
|
||||
|
||||
@@ -167,7 +168,7 @@ public class ExtendedCacheServiceCollectionExtensionsTests
|
||||
var settings = CreateGlobalSettings(new()
|
||||
{
|
||||
{ "GlobalSettings:DistributedCache:Redis:ConnectionString", "localhost:6379" },
|
||||
{ "GlobalSettings:DistributedCache:DefaultExtendedCache:UseSharedRedisCache", "true" }
|
||||
{ "GlobalSettings:DistributedCache:DefaultExtendedCache:UseSharedDistributedCache", "true" }
|
||||
});
|
||||
|
||||
// Provide a multiplexer (shared)
|
||||
@@ -187,7 +188,7 @@ public class ExtendedCacheServiceCollectionExtensionsTests
|
||||
{
|
||||
var settings = new GlobalSettings.ExtendedCacheSettings
|
||||
{
|
||||
UseSharedRedisCache = false,
|
||||
UseSharedDistributedCache = false,
|
||||
Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "invalid:9999" }
|
||||
};
|
||||
|
||||
@@ -242,7 +243,7 @@ public class ExtendedCacheServiceCollectionExtensionsTests
|
||||
{
|
||||
var settings = new GlobalSettings.ExtendedCacheSettings
|
||||
{
|
||||
UseSharedRedisCache = false,
|
||||
UseSharedDistributedCache = false,
|
||||
// No Redis connection string
|
||||
};
|
||||
|
||||
@@ -261,13 +262,13 @@ public class ExtendedCacheServiceCollectionExtensionsTests
|
||||
var settingsA = new GlobalSettings.ExtendedCacheSettings
|
||||
{
|
||||
EnableDistributedCache = true,
|
||||
UseSharedRedisCache = false,
|
||||
UseSharedDistributedCache = false,
|
||||
Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "localhost:6379" }
|
||||
};
|
||||
var settingsB = new GlobalSettings.ExtendedCacheSettings
|
||||
{
|
||||
EnableDistributedCache = true,
|
||||
UseSharedRedisCache = false,
|
||||
UseSharedDistributedCache = false,
|
||||
Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "localhost:6380" }
|
||||
};
|
||||
|
||||
@@ -294,7 +295,7 @@ public class ExtendedCacheServiceCollectionExtensionsTests
|
||||
|
||||
var settings = new GlobalSettings.ExtendedCacheSettings
|
||||
{
|
||||
UseSharedRedisCache = false,
|
||||
UseSharedDistributedCache = false,
|
||||
Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "localhost:6379" }
|
||||
};
|
||||
|
||||
@@ -306,6 +307,180 @@ public class ExtendedCacheServiceCollectionExtensionsTests
|
||||
Assert.Same(existingCache, resolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddExtendedCache_SharedNonRedisCache_UsesDistributedCacheWithoutBackplane()
|
||||
{
|
||||
var settings = new GlobalSettings.ExtendedCacheSettings
|
||||
{
|
||||
UseSharedDistributedCache = true,
|
||||
EnableDistributedCache = true,
|
||||
// No Redis.ConnectionString
|
||||
};
|
||||
|
||||
// Register non-Redis distributed cache
|
||||
_services.AddSingleton(Substitute.For<IDistributedCache>());
|
||||
|
||||
_services.AddExtendedCache(_cacheName, _globalSettings, settings);
|
||||
|
||||
using var provider = _services.BuildServiceProvider();
|
||||
var cache = provider.GetRequiredKeyedService<IFusionCache>(_cacheName);
|
||||
|
||||
Assert.True(cache.HasDistributedCache);
|
||||
Assert.False(cache.HasBackplane); // No backplane for non-Redis
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddExtendedCache_SharedRedisWithMockedMultiplexer_ReusesExistingMultiplexer()
|
||||
{
|
||||
// Override GlobalSettings to include Redis connection string
|
||||
var globalSettings = CreateGlobalSettings(new()
|
||||
{
|
||||
{ "GlobalSettings:DistributedCache:Redis:ConnectionString", "localhost:6379" }
|
||||
});
|
||||
|
||||
// Custom settings for this cache
|
||||
var settings = new GlobalSettings.ExtendedCacheSettings
|
||||
{
|
||||
UseSharedDistributedCache = true,
|
||||
EnableDistributedCache = true,
|
||||
};
|
||||
|
||||
// Pre-register mocked multiplexer (simulates AddDistributedCache already called)
|
||||
var mockMultiplexer = Substitute.For<IConnectionMultiplexer>();
|
||||
_services.AddSingleton(mockMultiplexer);
|
||||
|
||||
_services.AddExtendedCache(_cacheName, globalSettings, settings);
|
||||
|
||||
using var provider = _services.BuildServiceProvider();
|
||||
var cache = provider.GetRequiredKeyedService<IFusionCache>(_cacheName);
|
||||
|
||||
Assert.True(cache.HasDistributedCache);
|
||||
Assert.True(cache.HasBackplane);
|
||||
|
||||
// Verify same multiplexer was reused (TryAdd didn't replace it)
|
||||
var resolvedMux = provider.GetRequiredService<IConnectionMultiplexer>();
|
||||
Assert.Same(mockMultiplexer, resolvedMux);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddExtendedCache_KeyedNonRedisCache_UsesKeyedDistributedCacheWithoutBackplane()
|
||||
{
|
||||
var settings = new GlobalSettings.ExtendedCacheSettings
|
||||
{
|
||||
UseSharedDistributedCache = false,
|
||||
EnableDistributedCache = true,
|
||||
// No Redis.ConnectionString
|
||||
};
|
||||
|
||||
// Register keyed non-Redis distributed cache
|
||||
_services.AddKeyedSingleton(_cacheName, Substitute.For<IDistributedCache>());
|
||||
|
||||
_services.AddExtendedCache(_cacheName, _globalSettings, settings);
|
||||
|
||||
using var provider = _services.BuildServiceProvider();
|
||||
var cache = provider.GetRequiredKeyedService<IFusionCache>(_cacheName);
|
||||
|
||||
Assert.True(cache.HasDistributedCache);
|
||||
Assert.False(cache.HasBackplane);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddExtendedCache_KeyedRedisWithConnectionString_CreatesIsolatedInfrastructure()
|
||||
{
|
||||
var settings = new GlobalSettings.ExtendedCacheSettings
|
||||
{
|
||||
UseSharedDistributedCache = false,
|
||||
EnableDistributedCache = true,
|
||||
Redis = new GlobalSettings.ConnectionStringSettings
|
||||
{
|
||||
ConnectionString = "localhost:6379"
|
||||
}
|
||||
};
|
||||
|
||||
// Pre-register mocked keyed multiplexer to avoid connection attempt
|
||||
_services.AddKeyedSingleton(_cacheName, Substitute.For<IConnectionMultiplexer>());
|
||||
|
||||
_services.AddExtendedCache(_cacheName, _globalSettings, settings);
|
||||
|
||||
using var provider = _services.BuildServiceProvider();
|
||||
var cache = provider.GetRequiredKeyedService<IFusionCache>(_cacheName);
|
||||
|
||||
Assert.True(cache.HasDistributedCache);
|
||||
Assert.True(cache.HasBackplane);
|
||||
|
||||
// Verify keyed services exist
|
||||
var keyedMux = provider.GetRequiredKeyedService<IConnectionMultiplexer>(_cacheName);
|
||||
Assert.NotNull(keyedMux);
|
||||
var keyedRedis = provider.GetRequiredKeyedService<IDistributedCache>(_cacheName);
|
||||
Assert.NotNull(keyedRedis);
|
||||
var keyedBackplane = provider.GetRequiredKeyedService<IFusionCacheBackplane>(_cacheName);
|
||||
Assert.NotNull(keyedBackplane);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddExtendedCache_NoDistributedCacheRegistered_WorksWithMemoryOnly()
|
||||
{
|
||||
var settings = new GlobalSettings.ExtendedCacheSettings
|
||||
{
|
||||
UseSharedDistributedCache = true,
|
||||
EnableDistributedCache = true,
|
||||
// No Redis connection string, no IDistributedCache registered
|
||||
// This is technically a misconfiguration, but we handle it without failing
|
||||
};
|
||||
|
||||
_services.AddExtendedCache(_cacheName, _globalSettings, settings);
|
||||
|
||||
using var provider = _services.BuildServiceProvider();
|
||||
var cache = provider.GetRequiredKeyedService<IFusionCache>(_cacheName);
|
||||
|
||||
Assert.False(cache.HasDistributedCache);
|
||||
Assert.False(cache.HasBackplane);
|
||||
|
||||
// Verify L1 memory cache still works
|
||||
cache.Set("key", "value");
|
||||
var result = cache.GetOrDefault<string>("key");
|
||||
Assert.Equal("value", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddExtendedCache_MultipleKeyedCachesWithDifferentTypes_EachHasCorrectConfig()
|
||||
{
|
||||
var redisSettings = new GlobalSettings.ExtendedCacheSettings
|
||||
{
|
||||
UseSharedDistributedCache = false,
|
||||
EnableDistributedCache = true,
|
||||
Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "localhost:6379" }
|
||||
};
|
||||
|
||||
var nonRedisSettings = new GlobalSettings.ExtendedCacheSettings
|
||||
{
|
||||
UseSharedDistributedCache = false,
|
||||
EnableDistributedCache = true,
|
||||
// No Redis connection string
|
||||
};
|
||||
|
||||
// Setup Cache1 (Redis)
|
||||
_services.AddKeyedSingleton("Cache1", Substitute.For<IConnectionMultiplexer>());
|
||||
_services.AddExtendedCache("Cache1", _globalSettings, redisSettings);
|
||||
|
||||
// Setup Cache2 (non-Redis)
|
||||
_services.AddKeyedSingleton("Cache2", Substitute.For<IDistributedCache>());
|
||||
_services.AddExtendedCache("Cache2", _globalSettings, nonRedisSettings);
|
||||
|
||||
using var provider = _services.BuildServiceProvider();
|
||||
|
||||
var cache1 = provider.GetRequiredKeyedService<IFusionCache>("Cache1");
|
||||
var cache2 = provider.GetRequiredKeyedService<IFusionCache>("Cache2");
|
||||
|
||||
Assert.True(cache1.HasDistributedCache);
|
||||
Assert.True(cache1.HasBackplane);
|
||||
|
||||
Assert.True(cache2.HasDistributedCache);
|
||||
Assert.False(cache2.HasBackplane);
|
||||
|
||||
Assert.NotSame(cache1, cache2);
|
||||
}
|
||||
|
||||
private static GlobalSettings CreateGlobalSettings(Dictionary<string, string?> data)
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
|
||||
@@ -89,6 +89,286 @@ public class ProviderUserRepositoryTests
|
||||
Assert.Equal(serializedSsoConfigData, orgWithSsoDetails.SsoConfig);
|
||||
}
|
||||
|
||||
[Theory, DatabaseData]
|
||||
public async Task GetManyByManyUsersAsync_WithMultipleUsers_ReturnsAllProviderUsers(
|
||||
IUserRepository userRepository,
|
||||
IProviderRepository providerRepository,
|
||||
IProviderUserRepository providerUserRepository)
|
||||
{
|
||||
var user1 = await userRepository.CreateTestUserAsync();
|
||||
var user2 = await userRepository.CreateTestUserAsync();
|
||||
var user3 = await userRepository.CreateTestUserAsync();
|
||||
|
||||
var provider1 = await providerRepository.CreateAsync(new Provider
|
||||
{
|
||||
Name = "Test Provider 1",
|
||||
Enabled = true,
|
||||
Type = ProviderType.Msp
|
||||
});
|
||||
|
||||
var provider2 = await providerRepository.CreateAsync(new Provider
|
||||
{
|
||||
Name = "Test Provider 2",
|
||||
Enabled = true,
|
||||
Type = ProviderType.Reseller
|
||||
});
|
||||
|
||||
var providerUser1 = await providerUserRepository.CreateAsync(new ProviderUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProviderId = provider1.Id,
|
||||
UserId = user1.Id,
|
||||
Status = ProviderUserStatusType.Confirmed,
|
||||
Type = ProviderUserType.ProviderAdmin
|
||||
});
|
||||
|
||||
var providerUser2 = await providerUserRepository.CreateAsync(new ProviderUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProviderId = provider1.Id,
|
||||
UserId = user2.Id,
|
||||
Status = ProviderUserStatusType.Invited,
|
||||
Type = ProviderUserType.ServiceUser
|
||||
});
|
||||
|
||||
var providerUser3 = await providerUserRepository.CreateAsync(new ProviderUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProviderId = provider2.Id,
|
||||
UserId = user3.Id,
|
||||
Status = ProviderUserStatusType.Confirmed,
|
||||
Type = ProviderUserType.ProviderAdmin
|
||||
});
|
||||
|
||||
var userIds = new[] { user1.Id, user2.Id, user3.Id };
|
||||
|
||||
var results = (await providerUserRepository.GetManyByManyUsersAsync(userIds)).ToList();
|
||||
|
||||
Assert.Equal(3, results.Count);
|
||||
Assert.Contains(results, pu => pu.Id == providerUser1.Id && pu.UserId == user1.Id);
|
||||
Assert.Contains(results, pu => pu.Id == providerUser2.Id && pu.UserId == user2.Id);
|
||||
Assert.Contains(results, pu => pu.Id == providerUser3.Id && pu.UserId == user3.Id);
|
||||
}
|
||||
|
||||
[Theory, DatabaseData]
|
||||
public async Task GetManyByManyUsersAsync_WithSingleUser_ReturnsSingleProviderUser(
|
||||
IUserRepository userRepository,
|
||||
IProviderRepository providerRepository,
|
||||
IProviderUserRepository providerUserRepository)
|
||||
{
|
||||
var user = await userRepository.CreateTestUserAsync();
|
||||
|
||||
var provider = await providerRepository.CreateAsync(new Provider
|
||||
{
|
||||
Name = "Test Provider",
|
||||
Enabled = true,
|
||||
Type = ProviderType.Msp
|
||||
});
|
||||
|
||||
var providerUser = await providerUserRepository.CreateAsync(new ProviderUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProviderId = provider.Id,
|
||||
UserId = user.Id,
|
||||
Status = ProviderUserStatusType.Confirmed,
|
||||
Type = ProviderUserType.ProviderAdmin
|
||||
});
|
||||
|
||||
var results = (await providerUserRepository.GetManyByManyUsersAsync([user.Id])).ToList();
|
||||
|
||||
Assert.Single(results);
|
||||
Assert.Equal(user.Id, results[0].UserId);
|
||||
Assert.Equal(provider.Id, results[0].ProviderId);
|
||||
}
|
||||
|
||||
[Theory, DatabaseData]
|
||||
public async Task GetManyByManyUsersAsync_WithUserHavingMultipleProviders_ReturnsAllProviderUsers(
|
||||
IUserRepository userRepository,
|
||||
IProviderRepository providerRepository,
|
||||
IProviderUserRepository providerUserRepository)
|
||||
{
|
||||
var user = await userRepository.CreateTestUserAsync();
|
||||
|
||||
var provider1 = await providerRepository.CreateAsync(new Provider
|
||||
{
|
||||
Name = "Test Provider 1",
|
||||
Enabled = true,
|
||||
Type = ProviderType.Msp
|
||||
});
|
||||
|
||||
var provider2 = await providerRepository.CreateAsync(new Provider
|
||||
{
|
||||
Name = "Test Provider 2",
|
||||
Enabled = true,
|
||||
Type = ProviderType.Reseller
|
||||
});
|
||||
|
||||
var providerUser1 = await providerUserRepository.CreateAsync(new ProviderUser
|
||||
{
|
||||
ProviderId = provider1.Id,
|
||||
UserId = user.Id,
|
||||
Status = ProviderUserStatusType.Confirmed,
|
||||
Type = ProviderUserType.ProviderAdmin
|
||||
});
|
||||
|
||||
var providerUser2 = await providerUserRepository.CreateAsync(new ProviderUser
|
||||
{
|
||||
ProviderId = provider2.Id,
|
||||
UserId = user.Id,
|
||||
Status = ProviderUserStatusType.Confirmed,
|
||||
Type = ProviderUserType.ServiceUser
|
||||
});
|
||||
|
||||
var results = (await providerUserRepository.GetManyByManyUsersAsync([user.Id])).ToList();
|
||||
|
||||
Assert.Equal(2, results.Count);
|
||||
Assert.Contains(results, pu => pu.Id == providerUser1.Id);
|
||||
Assert.Contains(results, pu => pu.Id == providerUser2.Id);
|
||||
}
|
||||
|
||||
[Theory, DatabaseData]
|
||||
public async Task GetManyByManyUsersAsync_WithEmptyUserIds_ReturnsEmpty(
|
||||
IProviderUserRepository providerUserRepository)
|
||||
{
|
||||
var results = await providerUserRepository.GetManyByManyUsersAsync(Array.Empty<Guid>());
|
||||
|
||||
Assert.Empty(results);
|
||||
}
|
||||
|
||||
[Theory, DatabaseData]
|
||||
public async Task GetManyByManyUsersAsync_WithNonExistentUserIds_ReturnsEmpty(
|
||||
IProviderUserRepository providerUserRepository)
|
||||
{
|
||||
var nonExistentUserIds = new[] { Guid.NewGuid(), Guid.NewGuid() };
|
||||
|
||||
var results = await providerUserRepository.GetManyByManyUsersAsync(nonExistentUserIds);
|
||||
|
||||
Assert.Empty(results);
|
||||
}
|
||||
|
||||
[Theory, DatabaseData]
|
||||
public async Task GetManyByManyUsersAsync_WithMixedExistentAndNonExistentUserIds_ReturnsOnlyExistent(
|
||||
IUserRepository userRepository,
|
||||
IProviderRepository providerRepository,
|
||||
IProviderUserRepository providerUserRepository)
|
||||
{
|
||||
var existingUser = await userRepository.CreateTestUserAsync();
|
||||
|
||||
var provider = await providerRepository.CreateAsync(new Provider
|
||||
{
|
||||
Name = "Test Provider",
|
||||
Enabled = true,
|
||||
Type = ProviderType.Msp
|
||||
});
|
||||
|
||||
var providerUser = await providerUserRepository.CreateAsync(new ProviderUser
|
||||
{
|
||||
ProviderId = provider.Id,
|
||||
UserId = existingUser.Id,
|
||||
Status = ProviderUserStatusType.Confirmed,
|
||||
Type = ProviderUserType.ProviderAdmin
|
||||
});
|
||||
|
||||
var userIds = new[] { existingUser.Id, Guid.NewGuid(), Guid.NewGuid() };
|
||||
|
||||
var results = (await providerUserRepository.GetManyByManyUsersAsync(userIds)).ToList();
|
||||
|
||||
Assert.Single(results);
|
||||
Assert.Equal(existingUser.Id, results[0].UserId);
|
||||
}
|
||||
|
||||
[Theory, DatabaseData]
|
||||
public async Task GetManyByManyUsersAsync_ReturnsAllStatuses(
|
||||
IUserRepository userRepository,
|
||||
IProviderRepository providerRepository,
|
||||
IProviderUserRepository providerUserRepository)
|
||||
{
|
||||
var user1 = await userRepository.CreateTestUserAsync();
|
||||
var user2 = await userRepository.CreateTestUserAsync();
|
||||
var user3 = await userRepository.CreateTestUserAsync();
|
||||
|
||||
var provider = await providerRepository.CreateAsync(new Provider
|
||||
{
|
||||
Name = "Test Provider",
|
||||
Enabled = true,
|
||||
Type = ProviderType.Msp
|
||||
});
|
||||
|
||||
await providerUserRepository.CreateAsync(new ProviderUser
|
||||
{
|
||||
ProviderId = provider.Id,
|
||||
UserId = user1.Id,
|
||||
Status = ProviderUserStatusType.Invited,
|
||||
Type = ProviderUserType.ServiceUser
|
||||
});
|
||||
|
||||
await providerUserRepository.CreateAsync(new ProviderUser
|
||||
{
|
||||
ProviderId = provider.Id,
|
||||
UserId = user2.Id,
|
||||
Status = ProviderUserStatusType.Accepted,
|
||||
Type = ProviderUserType.ServiceUser
|
||||
});
|
||||
|
||||
await providerUserRepository.CreateAsync(new ProviderUser
|
||||
{
|
||||
ProviderId = provider.Id,
|
||||
UserId = user3.Id,
|
||||
Status = ProviderUserStatusType.Confirmed,
|
||||
Type = ProviderUserType.ProviderAdmin
|
||||
});
|
||||
|
||||
var userIds = new[] { user1.Id, user2.Id, user3.Id };
|
||||
|
||||
var results = (await providerUserRepository.GetManyByManyUsersAsync(userIds)).ToList();
|
||||
|
||||
Assert.Equal(3, results.Count);
|
||||
Assert.Contains(results, pu => pu.UserId == user1.Id && pu.Status == ProviderUserStatusType.Invited);
|
||||
Assert.Contains(results, pu => pu.UserId == user2.Id && pu.Status == ProviderUserStatusType.Accepted);
|
||||
Assert.Contains(results, pu => pu.UserId == user3.Id && pu.Status == ProviderUserStatusType.Confirmed);
|
||||
}
|
||||
|
||||
[Theory, DatabaseData]
|
||||
public async Task GetManyByManyUsersAsync_ReturnsAllProviderUserTypes(
|
||||
IUserRepository userRepository,
|
||||
IProviderRepository providerRepository,
|
||||
IProviderUserRepository providerUserRepository)
|
||||
{
|
||||
var user1 = await userRepository.CreateTestUserAsync();
|
||||
var user2 = await userRepository.CreateTestUserAsync();
|
||||
|
||||
var provider = await providerRepository.CreateAsync(new Provider
|
||||
{
|
||||
Name = "Test Provider",
|
||||
Enabled = true,
|
||||
Type = ProviderType.Msp
|
||||
});
|
||||
|
||||
await providerUserRepository.CreateAsync(new ProviderUser
|
||||
{
|
||||
ProviderId = provider.Id,
|
||||
UserId = user1.Id,
|
||||
Status = ProviderUserStatusType.Confirmed,
|
||||
Type = ProviderUserType.ServiceUser
|
||||
});
|
||||
|
||||
await providerUserRepository.CreateAsync(new ProviderUser
|
||||
{
|
||||
ProviderId = provider.Id,
|
||||
UserId = user2.Id,
|
||||
Status = ProviderUserStatusType.Confirmed,
|
||||
Type = ProviderUserType.ProviderAdmin
|
||||
});
|
||||
|
||||
var userIds = new[] { user1.Id, user2.Id };
|
||||
|
||||
var results = (await providerUserRepository.GetManyByManyUsersAsync(userIds)).ToList();
|
||||
|
||||
Assert.Equal(2, results.Count);
|
||||
Assert.Contains(results, pu => pu.UserId == user1.Id && pu.Type == ProviderUserType.ServiceUser);
|
||||
Assert.Contains(results, pu => pu.UserId == user2.Id && pu.Type == ProviderUserType.ProviderAdmin);
|
||||
}
|
||||
|
||||
private static void AssertProviderOrganizationDetails(
|
||||
ProviderUserOrganizationDetails actual,
|
||||
Organization expectedOrganization,
|
||||
@@ -139,4 +419,6 @@ public class ProviderUserRepositoryTests
|
||||
Assert.Equal(expectedProviderUser.Status, actual.Status);
|
||||
Assert.Equal(expectedProviderUser.Type, actual.Type);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -34,6 +34,6 @@ public class Program
|
||||
var db = scopedServices.GetRequiredService<DatabaseContext>();
|
||||
|
||||
var recipe = new OrganizationWithUsersRecipe(db);
|
||||
recipe.Seed(name, users, domain);
|
||||
recipe.Seed(name: name, domain: domain, users: users);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE OR ALTER PROCEDURE [dbo].[ProviderUser_ReadManyByManyUserIds]
|
||||
@UserIds AS [dbo].[GuidIdArray] READONLY
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
SELECT
|
||||
[pu].*
|
||||
FROM
|
||||
[dbo].[ProviderUserView] AS [pu]
|
||||
INNER JOIN
|
||||
@UserIds [u] ON [u].[Id] = [pu].[UserId]
|
||||
END
|
||||
@@ -0,0 +1,20 @@
|
||||
CREATE OR ALTER PROCEDURE [dbo].[OrganizationIntegrationConfigurationDetails_ReadManyByEventTypeOrganizationIdIntegrationType]
|
||||
@EventType SMALLINT,
|
||||
@OrganizationId UNIQUEIDENTIFIER,
|
||||
@IntegrationType SMALLINT
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
SELECT
|
||||
oic.*
|
||||
FROM
|
||||
[dbo].[OrganizationIntegrationConfigurationDetailsView] oic
|
||||
WHERE
|
||||
(oic.[EventType] = @EventType OR oic.[EventType] IS NULL)
|
||||
AND
|
||||
oic.[OrganizationId] = @OrganizationId
|
||||
AND
|
||||
oic.[IntegrationType] = @IntegrationType
|
||||
END
|
||||
GO
|
||||
@@ -17,7 +17,31 @@ public class OrganizationSeeder
|
||||
Plan = "Enterprise (Annually)",
|
||||
PlanType = PlanType.EnterpriseAnnually,
|
||||
Seats = seats,
|
||||
|
||||
UseCustomPermissions = true,
|
||||
UseOrganizationDomains = true,
|
||||
UseSecretsManager = true,
|
||||
UseGroups = true,
|
||||
UseDirectory = true,
|
||||
UseEvents = true,
|
||||
UseTotp = true,
|
||||
Use2fa = true,
|
||||
UseApi = true,
|
||||
UseResetPassword = true,
|
||||
UsePasswordManager = true,
|
||||
UseAutomaticUserConfirmation = true,
|
||||
SelfHost = true,
|
||||
UsersGetPremium = true,
|
||||
LimitCollectionCreation = true,
|
||||
LimitCollectionDeletion = true,
|
||||
LimitItemDeletion = true,
|
||||
AllowAdminAccessToAllCollectionItems = true,
|
||||
UseRiskInsights = true,
|
||||
UseAdminSponsoredFamilies = true,
|
||||
SyncSeats = true,
|
||||
Status = OrganizationStatusType.Created,
|
||||
//GatewayCustomerId = "example-customer-id",
|
||||
//GatewaySubscriptionId = "example-subscription-id",
|
||||
MaxStorageGb = 10,
|
||||
// Currently hardcoded to the values from https://github.com/bitwarden/sdk-internal/blob/main/crates/bitwarden-core/src/client/test_accounts.rs.
|
||||
// TODO: These should be dynamically generated by the SDK.
|
||||
PublicKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmIJbGMk6eZqVE7UxhZ46Weu2jKciqOiOkSVYtGvs61rfe9AXxtLaaZEKN4d4DmkZcF6dna2eXNxZmb7U4pwlttye8ksqISe6IUAZQox7auBpjopdCEPhKRg3BD/u8ks9UxSxgWe+fpebjt6gd5hsl1/5HOObn7SeU6EEU04cp3/eH7a4OTdXxB8oN62HGV9kM/ubM1goILgjoSJDbihMK0eb7b8hPHwcA/YOgKKiu/N3FighccdSMD5Pk+HfjacsFNZQa2EsqW09IvvSZ+iL6HQeZ1vwc/6TO1J7EOfJZFQcjoEL9LVI693efYoMZSmrPEWziZ4PvwpOOGo6OObyMQIDAQAB",
|
||||
@@ -28,17 +52,25 @@ public class OrganizationSeeder
|
||||
|
||||
public static class OrgnaizationExtensions
|
||||
{
|
||||
public static OrganizationUser CreateOrganizationUser(this Organization organization, User user)
|
||||
/// <summary>
|
||||
/// Creates an OrganizationUser with fields populated based on status.
|
||||
/// For Invited status, only user.Email is used. For other statuses, user.Id is used.
|
||||
/// </summary>
|
||||
public static OrganizationUser CreateOrganizationUser(
|
||||
this Organization organization, User user, OrganizationUserType type, OrganizationUserStatusType status)
|
||||
{
|
||||
var isInvited = status == OrganizationUserStatusType.Invited;
|
||||
var isConfirmed = status == OrganizationUserStatusType.Confirmed || status == OrganizationUserStatusType.Revoked;
|
||||
|
||||
return new OrganizationUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrganizationId = organization.Id,
|
||||
UserId = user.Id,
|
||||
|
||||
Key = "4.rY01mZFXHOsBAg5Fq4gyXuklWfm6mQASm42DJpx05a+e2mmp+P5W6r54WU2hlREX0uoTxyP91bKKwickSPdCQQ58J45LXHdr9t2uzOYyjVzpzebFcdMw1eElR9W2DW8wEk9+mvtWvKwu7yTebzND+46y1nRMoFydi5zPVLSlJEf81qZZ4Uh1UUMLwXz+NRWfixnGXgq2wRq1bH0n3mqDhayiG4LJKgGdDjWXC8W8MMXDYx24SIJrJu9KiNEMprJE+XVF9nQVNijNAjlWBqkDpsfaWTUfeVLRLctfAqW1blsmIv4RQ91PupYJZDNc8nO9ZTF3TEVM+2KHoxzDJrLs2Q==",
|
||||
Type = OrganizationUserType.Admin,
|
||||
Status = OrganizationUserStatusType.Confirmed
|
||||
UserId = isInvited ? null : user.Id,
|
||||
Email = isInvited ? user.Email : null,
|
||||
Key = isConfirmed ? "4.rY01mZFXHOsBAg5Fq4gyXuklWfm6mQASm42DJpx05a+e2mmp+P5W6r54WU2hlREX0uoTxyP91bKKwickSPdCQQ58J45LXHdr9t2uzOYyjVzpzebFcdMw1eElR9W2DW8wEk9+mvtWvKwu7yTebzND+46y1nRMoFydi5zPVLSlJEf81qZZ4Uh1UUMLwXz+NRWfixnGXgq2wRq1bH0n3mqDhayiG4LJKgGdDjWXC8W8MMXDYx24SIJrJu9KiNEMprJE+XVF9nQVNijNAjlWBqkDpsfaWTUfeVLRLctfAqW1blsmIv4RQ91PupYJZDNc8nO9ZTF3TEVM+2KHoxzDJrLs2Q==" : null,
|
||||
Type = type,
|
||||
Status = status
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
122
util/Seeder/Recipes/CollectionsRecipe.cs
Normal file
122
util/Seeder/Recipes/CollectionsRecipe.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Infrastructure.EntityFramework.Repositories;
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
|
||||
namespace Bit.Seeder.Recipes;
|
||||
|
||||
public class CollectionsRecipe(DatabaseContext db)
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds collections to an organization and creates relationships between users and collections.
|
||||
/// </summary>
|
||||
/// <param name="organizationId">The ID of the organization to add collections to.</param>
|
||||
/// <param name="collections">The number of collections to add.</param>
|
||||
/// <param name="organizationUserIds">The IDs of the users to create relationships with.</param>
|
||||
/// <param name="maxUsersWithRelationships">The maximum number of users to create relationships with.</param>
|
||||
public List<Guid> AddToOrganization(Guid organizationId, int collections, List<Guid> organizationUserIds, int maxUsersWithRelationships = 1000)
|
||||
{
|
||||
var collectionList = CreateAndSaveCollections(organizationId, collections);
|
||||
|
||||
if (collectionList.Any())
|
||||
{
|
||||
CreateAndSaveCollectionUserRelationships(collectionList, organizationUserIds, maxUsersWithRelationships);
|
||||
}
|
||||
|
||||
return collectionList.Select(c => c.Id).ToList();
|
||||
}
|
||||
|
||||
private List<Core.Entities.Collection> CreateAndSaveCollections(Guid organizationId, int count)
|
||||
{
|
||||
var collectionList = new List<Core.Entities.Collection>();
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
collectionList.Add(new Core.Entities.Collection
|
||||
{
|
||||
Id = CoreHelpers.GenerateComb(),
|
||||
OrganizationId = organizationId,
|
||||
Name = $"Collection {i + 1}",
|
||||
Type = CollectionType.SharedCollection,
|
||||
CreationDate = DateTime.UtcNow,
|
||||
RevisionDate = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
if (collectionList.Any())
|
||||
{
|
||||
db.BulkCopy(collectionList);
|
||||
}
|
||||
|
||||
return collectionList;
|
||||
}
|
||||
|
||||
private void CreateAndSaveCollectionUserRelationships(
|
||||
List<Core.Entities.Collection> collections,
|
||||
List<Guid> organizationUserIds,
|
||||
int maxUsersWithRelationships)
|
||||
{
|
||||
if (!organizationUserIds.Any() || maxUsersWithRelationships <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var collectionUsers = BuildCollectionUserRelationships(collections, organizationUserIds, maxUsersWithRelationships);
|
||||
|
||||
if (collectionUsers.Any())
|
||||
{
|
||||
db.BulkCopy(collectionUsers);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates user-to-collection relationships with varied assignment patterns for realistic test data.
|
||||
/// Each user gets 1-3 collections based on a rotating pattern.
|
||||
/// </summary>
|
||||
private List<Core.Entities.CollectionUser> BuildCollectionUserRelationships(
|
||||
List<Core.Entities.Collection> collections,
|
||||
List<Guid> organizationUserIds,
|
||||
int maxUsersWithRelationships)
|
||||
{
|
||||
var maxRelationships = Math.Min(organizationUserIds.Count, maxUsersWithRelationships);
|
||||
var collectionUsers = new List<Core.Entities.CollectionUser>();
|
||||
|
||||
for (var i = 0; i < maxRelationships; i++)
|
||||
{
|
||||
var orgUserId = organizationUserIds[i];
|
||||
var userCollectionAssignments = CreateCollectionAssignmentsForUser(collections, orgUserId, i);
|
||||
collectionUsers.AddRange(userCollectionAssignments);
|
||||
}
|
||||
|
||||
return collectionUsers;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assigns collections to a user with varying permissions.
|
||||
/// Pattern: 1-3 collections per user (cycles: 1, 2, 3, 1, 2, 3...).
|
||||
/// First collection has Manage rights, subsequent ones are ReadOnly.
|
||||
/// </summary>
|
||||
private List<Core.Entities.CollectionUser> CreateCollectionAssignmentsForUser(
|
||||
List<Core.Entities.Collection> collections,
|
||||
Guid organizationUserId,
|
||||
int userIndex)
|
||||
{
|
||||
var assignments = new List<Core.Entities.CollectionUser>();
|
||||
var userCollectionCount = (userIndex % 3) + 1; // Cycles through 1, 2, or 3 collections
|
||||
|
||||
for (var j = 0; j < userCollectionCount; j++)
|
||||
{
|
||||
var collectionIndex = (userIndex + j) % collections.Count; // Distribute across available collections
|
||||
assignments.Add(new Core.Entities.CollectionUser
|
||||
{
|
||||
CollectionId = collections[collectionIndex].Id,
|
||||
OrganizationUserId = organizationUserId,
|
||||
ReadOnly = j > 0, // First assignment gets write access
|
||||
HidePasswords = false,
|
||||
Manage = j == 0 // First assignment gets manage permissions
|
||||
});
|
||||
}
|
||||
|
||||
return assignments;
|
||||
}
|
||||
}
|
||||
94
util/Seeder/Recipes/GroupsRecipe.cs
Normal file
94
util/Seeder/Recipes/GroupsRecipe.cs
Normal file
@@ -0,0 +1,94 @@
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Infrastructure.EntityFramework.Repositories;
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
|
||||
namespace Bit.Seeder.Recipes;
|
||||
|
||||
public class GroupsRecipe(DatabaseContext db)
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds groups to an organization and creates relationships between users and groups.
|
||||
/// </summary>
|
||||
/// <param name="organizationId">The ID of the organization to add groups to.</param>
|
||||
/// <param name="groups">The number of groups to add.</param>
|
||||
/// <param name="organizationUserIds">The IDs of the users to create relationships with.</param>
|
||||
/// <param name="maxUsersWithRelationships">The maximum number of users to create relationships with.</param>
|
||||
public List<Guid> AddToOrganization(Guid organizationId, int groups, List<Guid> organizationUserIds, int maxUsersWithRelationships = 1000)
|
||||
{
|
||||
var groupList = CreateAndSaveGroups(organizationId, groups);
|
||||
|
||||
if (groupList.Any())
|
||||
{
|
||||
CreateAndSaveGroupUserRelationships(groupList, organizationUserIds, maxUsersWithRelationships);
|
||||
}
|
||||
|
||||
return groupList.Select(g => g.Id).ToList();
|
||||
}
|
||||
|
||||
private List<Core.AdminConsole.Entities.Group> CreateAndSaveGroups(Guid organizationId, int count)
|
||||
{
|
||||
var groupList = new List<Core.AdminConsole.Entities.Group>();
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
groupList.Add(new Core.AdminConsole.Entities.Group
|
||||
{
|
||||
Id = CoreHelpers.GenerateComb(),
|
||||
OrganizationId = organizationId,
|
||||
Name = $"Group {i + 1}"
|
||||
});
|
||||
}
|
||||
|
||||
if (groupList.Any())
|
||||
{
|
||||
db.BulkCopy(groupList);
|
||||
}
|
||||
|
||||
return groupList;
|
||||
}
|
||||
|
||||
private void CreateAndSaveGroupUserRelationships(
|
||||
List<Core.AdminConsole.Entities.Group> groups,
|
||||
List<Guid> organizationUserIds,
|
||||
int maxUsersWithRelationships)
|
||||
{
|
||||
if (!organizationUserIds.Any() || maxUsersWithRelationships <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var groupUsers = BuildGroupUserRelationships(groups, organizationUserIds, maxUsersWithRelationships);
|
||||
|
||||
if (groupUsers.Any())
|
||||
{
|
||||
db.BulkCopy(groupUsers);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates user-to-group relationships with distributed assignment patterns for realistic test data.
|
||||
/// Each user is assigned to one group, distributed evenly across available groups.
|
||||
/// </summary>
|
||||
private List<Core.AdminConsole.Entities.GroupUser> BuildGroupUserRelationships(
|
||||
List<Core.AdminConsole.Entities.Group> groups,
|
||||
List<Guid> organizationUserIds,
|
||||
int maxUsersWithRelationships)
|
||||
{
|
||||
var maxRelationships = Math.Min(organizationUserIds.Count, maxUsersWithRelationships);
|
||||
var groupUsers = new List<Core.AdminConsole.Entities.GroupUser>();
|
||||
|
||||
for (var i = 0; i < maxRelationships; i++)
|
||||
{
|
||||
var orgUserId = organizationUserIds[i];
|
||||
var groupIndex = i % groups.Count; // Round-robin distribution across groups
|
||||
|
||||
groupUsers.Add(new Core.AdminConsole.Entities.GroupUser
|
||||
{
|
||||
GroupId = groups[groupIndex].Id,
|
||||
OrganizationUserId = orgUserId
|
||||
});
|
||||
}
|
||||
|
||||
return groupUsers;
|
||||
}
|
||||
}
|
||||
25
util/Seeder/Recipes/OrganizationDomainRecipe.cs
Normal file
25
util/Seeder/Recipes/OrganizationDomainRecipe.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using Bit.Infrastructure.EntityFramework.Models;
|
||||
using Bit.Infrastructure.EntityFramework.Repositories;
|
||||
|
||||
namespace Bit.Seeder.Recipes;
|
||||
|
||||
public class OrganizationDomainRecipe(DatabaseContext db)
|
||||
{
|
||||
public void AddVerifiedDomainToOrganization(Guid organizationId, string domainName)
|
||||
{
|
||||
var domain = new OrganizationDomain
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrganizationId = organizationId,
|
||||
DomainName = domainName,
|
||||
Txt = Guid.NewGuid().ToString("N"),
|
||||
CreationDate = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
domain.SetVerifiedDate();
|
||||
domain.SetLastCheckedDate();
|
||||
|
||||
db.Add(domain);
|
||||
db.SaveChanges();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Bit.Infrastructure.EntityFramework.Models;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Infrastructure.EntityFramework.Models;
|
||||
using Bit.Infrastructure.EntityFramework.Repositories;
|
||||
using Bit.Seeder.Factories;
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
@@ -7,11 +8,12 @@ namespace Bit.Seeder.Recipes;
|
||||
|
||||
public class OrganizationWithUsersRecipe(DatabaseContext db)
|
||||
{
|
||||
public Guid Seed(string name, int users, string domain)
|
||||
public Guid Seed(string name, string domain, int users, OrganizationUserStatusType usersStatus = OrganizationUserStatusType.Confirmed)
|
||||
{
|
||||
var organization = OrganizationSeeder.CreateEnterprise(name, domain, users);
|
||||
var user = UserSeeder.CreateUser($"admin@{domain}");
|
||||
var orgUser = organization.CreateOrganizationUser(user);
|
||||
var seats = Math.Max(users + 1, 1000);
|
||||
var organization = OrganizationSeeder.CreateEnterprise(name, domain, seats);
|
||||
var ownerUser = UserSeeder.CreateUser($"owner@{domain}");
|
||||
var ownerOrgUser = organization.CreateOrganizationUser(ownerUser, OrganizationUserType.Owner, OrganizationUserStatusType.Confirmed);
|
||||
|
||||
var additionalUsers = new List<User>();
|
||||
var additionalOrgUsers = new List<OrganizationUser>();
|
||||
@@ -19,12 +21,12 @@ public class OrganizationWithUsersRecipe(DatabaseContext db)
|
||||
{
|
||||
var additionalUser = UserSeeder.CreateUser($"user{i}@{domain}");
|
||||
additionalUsers.Add(additionalUser);
|
||||
additionalOrgUsers.Add(organization.CreateOrganizationUser(additionalUser));
|
||||
additionalOrgUsers.Add(organization.CreateOrganizationUser(additionalUser, OrganizationUserType.User, usersStatus));
|
||||
}
|
||||
|
||||
db.Add(organization);
|
||||
db.Add(user);
|
||||
db.Add(orgUser);
|
||||
db.Add(ownerUser);
|
||||
db.Add(ownerOrgUser);
|
||||
|
||||
db.SaveChanges();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user