1
0
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:
Jimmy Vo
2025-12-05 14:51:10 -05:00
83 changed files with 4274 additions and 858 deletions

View File

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

View File

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

View File

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

View File

@@ -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"

View File

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

View File

@@ -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")]

View File

@@ -119,7 +119,7 @@ public class OrganizationUserResetPasswordEnrollmentRequestModel
public class OrganizationUserBulkRequestModel
{
[Required]
[Required, MinLength(1)]
public IEnumerable<Guid> Ids { get; set; }
}

View File

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

View File

@@ -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

View File

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

View File

@@ -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")
{

View File

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

View File

@@ -2,8 +2,6 @@
using Bit.Core.Enums;
using Bit.Core.Utilities;
#nullable enable
namespace Bit.Core.AdminConsole.Entities;
public class OrganizationIntegration : ITableObject<Guid>

View File

@@ -2,8 +2,6 @@
using Bit.Core.Enums;
using Bit.Core.Utilities;
#nullable enable
namespace Bit.Core.AdminConsole.Entities;
public class OrganizationIntegrationConfiguration : ITableObject<Guid>

View File

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

View File

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

View File

@@ -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
{

View File

@@ -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,

View File

@@ -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.");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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),

View File

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

View File

@@ -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

View File

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

View File

@@ -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";

View File

@@ -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";

View File

@@ -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.
Lets 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>

View File

@@ -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.
Lets 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>

View File

@@ -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.
Lets 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>

View File

@@ -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...`);

View File

@@ -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 {

View File

@@ -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="Lets get you set up to autofill."
/>
</mj-wrapper>

View File

@@ -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="Lets get you set up to autofill."
/>
</mj-wrapper>

View File

@@ -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="Lets get you set up to autofill."
/>
</mj-wrapper>

View File

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

View File

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

View File

@@ -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"

View File

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

View File

@@ -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,

View File

@@ -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))
{

View File

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

View File

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

View File

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

View File

@@ -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())

View File

@@ -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,

View File

@@ -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 =>

View File

@@ -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

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>

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

View File

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

View File

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

View 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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,

View File

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

View File

@@ -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()

View File

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

View File

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

View File

@@ -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

View File

@@ -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

View File

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

View 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;
}
}

View 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;
}
}

View 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();
}
}

View File

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