Merge branch 'main' into jmccannon/ac/pm-27131-auto-confirm-req

This commit is contained in:
Jared McCannon 2025-12-08 10:02:52 -06:00
commit 593c6a5ac5
No known key found for this signature in database
GPG Key ID: CF03F3DB01CE96A6
70 changed files with 2867 additions and 634 deletions

View File

@ -44,6 +44,7 @@
{
matchPackageNames: ["https://github.com/bitwarden/sdk-internal.git"],
groupName: "sdk-internal",
dependencyDashboardApproval: true
},
{
matchManagers: ["dockerfile", "docker-compose"],

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

@ -1,8 +1,8 @@
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@ -12,7 +12,10 @@ namespace Bit.Api.AdminConsole.Controllers;
[Authorize("Application")]
public class OrganizationIntegrationController(
ICurrentContext currentContext,
IOrganizationIntegrationRepository integrationRepository) : Controller
ICreateOrganizationIntegrationCommand createCommand,
IUpdateOrganizationIntegrationCommand updateCommand,
IDeleteOrganizationIntegrationCommand deleteCommand,
IGetOrganizationIntegrationsQuery getQuery) : Controller
{
[HttpGet("")]
public async Task<List<OrganizationIntegrationResponseModel>> GetAsync(Guid organizationId)
@ -22,7 +25,7 @@ public class OrganizationIntegrationController(
throw new NotFoundException();
}
var integrations = await integrationRepository.GetManyByOrganizationAsync(organizationId);
var integrations = await getQuery.GetManyByOrganizationAsync(organizationId);
return integrations
.Select(integration => new OrganizationIntegrationResponseModel(integration))
.ToList();
@ -36,8 +39,10 @@ public class OrganizationIntegrationController(
throw new NotFoundException();
}
var integration = await integrationRepository.CreateAsync(model.ToOrganizationIntegration(organizationId));
return new OrganizationIntegrationResponseModel(integration);
var integration = model.ToOrganizationIntegration(organizationId);
var created = await createCommand.CreateAsync(integration);
return new OrganizationIntegrationResponseModel(created);
}
[HttpPut("{integrationId:guid}")]
@ -48,14 +53,10 @@ public class OrganizationIntegrationController(
throw new NotFoundException();
}
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration is null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
var integration = model.ToOrganizationIntegration(organizationId);
var updated = await updateCommand.UpdateAsync(organizationId, integrationId, integration);
await integrationRepository.ReplaceAsync(model.ToOrganizationIntegration(integration));
return new OrganizationIntegrationResponseModel(integration);
return new OrganizationIntegrationResponseModel(updated);
}
[HttpDelete("{integrationId:guid}")]
@ -66,13 +67,7 @@ public class OrganizationIntegrationController(
throw new NotFoundException();
}
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration is null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
await integrationRepository.DeleteAsync(integration);
await deleteCommand.DeleteAsync(organizationId, integrationId);
}
[HttpPost("{integrationId:guid}/delete")]

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

@ -226,7 +226,8 @@ public class Startup
services.AddHostedService<Core.HostedServices.ApplicationCacheHostedService>();
}
// Add Slack / Teams Services for OAuth API requests - if configured
// Add Event Integrations services
services.AddEventIntegrationsCommandsQueries(globalSettings);
services.AddSlackService(globalSettings);
services.AddTeamsService(globalSettings);
}

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,38 @@
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Microsoft.Extensions.DependencyInjection;
public static class EventIntegrationsServiceCollectionExtensions
{
/// <summary>
/// Adds all event integrations commands, queries, and required cache infrastructure.
/// This method is idempotent and can be called multiple times safely.
/// </summary>
public static IServiceCollection AddEventIntegrationsCommandsQueries(
this IServiceCollection services,
GlobalSettings globalSettings)
{
// Ensure cache is registered first - commands depend on this keyed cache.
// This is idempotent for the same named cache, so it's safe to call.
services.AddExtendedCache(EventIntegrationsCacheConstants.CacheName, globalSettings);
// Add all commands/queries
services.AddOrganizationIntegrationCommandsQueries();
return services;
}
internal static IServiceCollection AddOrganizationIntegrationCommandsQueries(this IServiceCollection services)
{
services.TryAddScoped<ICreateOrganizationIntegrationCommand, CreateOrganizationIntegrationCommand>();
services.TryAddScoped<IUpdateOrganizationIntegrationCommand, UpdateOrganizationIntegrationCommand>();
services.TryAddScoped<IDeleteOrganizationIntegrationCommand, DeleteOrganizationIntegrationCommand>();
services.TryAddScoped<IGetOrganizationIntegrationsQuery, GetOrganizationIntegrationsQuery>();
return services;
}
}

View File

@ -0,0 +1,38 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.Extensions.DependencyInjection;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations;
/// <summary>
/// Command implementation for creating organization integrations with cache invalidation support.
/// </summary>
public class CreateOrganizationIntegrationCommand(
IOrganizationIntegrationRepository integrationRepository,
[FromKeyedServices(EventIntegrationsCacheConstants.CacheName)]
IFusionCache cache)
: ICreateOrganizationIntegrationCommand
{
public async Task<OrganizationIntegration> CreateAsync(OrganizationIntegration integration)
{
var existingIntegrations = await integrationRepository
.GetManyByOrganizationAsync(integration.OrganizationId);
if (existingIntegrations.Any(i => i.Type == integration.Type))
{
throw new BadRequestException("An integration of this type already exists for this organization.");
}
var created = await integrationRepository.CreateAsync(integration);
await cache.RemoveByTagAsync(
EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
organizationId: integration.OrganizationId,
integrationType: integration.Type
));
return created;
}
}

View File

@ -0,0 +1,33 @@
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.Extensions.DependencyInjection;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations;
/// <summary>
/// Command implementation for deleting organization integrations with cache invalidation support.
/// </summary>
public class DeleteOrganizationIntegrationCommand(
IOrganizationIntegrationRepository integrationRepository,
[FromKeyedServices(EventIntegrationsCacheConstants.CacheName)] IFusionCache cache)
: IDeleteOrganizationIntegrationCommand
{
public async Task DeleteAsync(Guid organizationId, Guid integrationId)
{
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration is null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
await integrationRepository.DeleteAsync(integration);
await cache.RemoveByTagAsync(
EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
organizationId: organizationId,
integrationType: integration.Type
));
}
}

View File

@ -0,0 +1,18 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
using Bit.Core.Repositories;
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations;
/// <summary>
/// Query implementation for retrieving organization integrations.
/// </summary>
public class GetOrganizationIntegrationsQuery(IOrganizationIntegrationRepository integrationRepository)
: IGetOrganizationIntegrationsQuery
{
public async Task<List<OrganizationIntegration>> GetManyByOrganizationAsync(Guid organizationId)
{
var integrations = await integrationRepository.GetManyByOrganizationAsync(organizationId);
return integrations.ToList();
}
}

View File

@ -0,0 +1,18 @@
using Bit.Core.AdminConsole.Entities;
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
/// <summary>
/// Command interface for creating an OrganizationIntegration.
/// </summary>
public interface ICreateOrganizationIntegrationCommand
{
/// <summary>
/// Creates a new organization integration.
/// </summary>
/// <param name="integration">The OrganizationIntegration to create.</param>
/// <returns>The created OrganizationIntegration.</returns>
/// <exception cref="Exceptions.BadRequestException">Thrown when an integration
/// of the same type already exists for the organization.</exception>
Task<OrganizationIntegration> CreateAsync(OrganizationIntegration integration);
}

View File

@ -0,0 +1,16 @@
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
/// <summary>
/// Command interface for deleting organization integrations.
/// </summary>
public interface IDeleteOrganizationIntegrationCommand
{
/// <summary>
/// Deletes an organization integration.
/// </summary>
/// <param name="organizationId">The unique identifier of the organization.</param>
/// <param name="integrationId">The unique identifier of the integration to delete.</param>
/// <exception cref="Exceptions.NotFoundException">Thrown when the integration does not exist
/// or does not belong to the specified organization.</exception>
Task DeleteAsync(Guid organizationId, Guid integrationId);
}

View File

@ -0,0 +1,16 @@
using Bit.Core.AdminConsole.Entities;
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
/// <summary>
/// Query interface for retrieving organization integrations.
/// </summary>
public interface IGetOrganizationIntegrationsQuery
{
/// <summary>
/// Retrieves all organization integrations for a specific organization.
/// </summary>
/// <param name="organizationId">The unique identifier of the organization.</param>
/// <returns>A list of organization integrations associated with the organization.</returns>
Task<List<OrganizationIntegration>> GetManyByOrganizationAsync(Guid organizationId);
}

View File

@ -0,0 +1,20 @@
using Bit.Core.AdminConsole.Entities;
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
/// <summary>
/// Command interface for updating organization integrations.
/// </summary>
public interface IUpdateOrganizationIntegrationCommand
{
/// <summary>
/// Updates an existing organization integration.
/// </summary>
/// <param name="organizationId">The unique identifier of the organization.</param>
/// <param name="integrationId">The unique identifier of the integration to update.</param>
/// <param name="updatedIntegration">The updated organization integration data.</param>
/// <returns>The updated organization integration.</returns>
/// <exception cref="Exceptions.NotFoundException">Thrown when the integration does not exist,
/// does not belong to the specified organization, or the integration type does not match.</exception>
Task<OrganizationIntegration> UpdateAsync(Guid organizationId, Guid integrationId, OrganizationIntegration updatedIntegration);
}

View File

@ -0,0 +1,45 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.Extensions.DependencyInjection;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations;
/// <summary>
/// Command implementation for updating organization integrations with cache invalidation support.
/// </summary>
public class UpdateOrganizationIntegrationCommand(
IOrganizationIntegrationRepository integrationRepository,
[FromKeyedServices(EventIntegrationsCacheConstants.CacheName)]
IFusionCache cache)
: IUpdateOrganizationIntegrationCommand
{
public async Task<OrganizationIntegration> UpdateAsync(
Guid organizationId,
Guid integrationId,
OrganizationIntegration updatedIntegration)
{
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration is null ||
integration.OrganizationId != organizationId ||
integration.Type != updatedIntegration.Type)
{
throw new NotFoundException();
}
updatedIntegration.Id = integration.Id;
updatedIntegration.OrganizationId = integration.OrganizationId;
updatedIntegration.CreationDate = integration.CreationDate;
await integrationRepository.ReplaceAsync(updatedIntegration);
await cache.RemoveByTagAsync(
EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
organizationId: organizationId,
integrationType: integration.Type
));
return updatedIntegration;
}
}

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

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

@ -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,60 @@ 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
[CACHING in Utilities](https://github.com/bitwarden/server/blob/main/src/Core/Utilities/CACHING.md)
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 `CreateOrganizationIntegrationCommand`, `UpdateOrganizationIntegrationCommand`, and
`DeleteOrganizationIntegrationCommand` commands need to use the tag to remove all the tagged entries when an 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

@ -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";
@ -242,15 +243,14 @@ public static class FeatureFlagKeys
/* Vault Team */
public const string CipherKeyEncryption = "cipher-key-encryption";
public const string PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk";
public const string EndUserNotifications = "pm-10609-end-user-notifications";
public const string PhishingDetection = "phishing-detection";
public const string RemoveCardItemTypePolicy = "pm-16442-remove-card-item-type-policy";
public const string PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view";
public const string PM19315EndUserActivationMvp = "pm-19315-end-user-activation-mvp";
public const string PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption";
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

@ -23,8 +23,8 @@
<ItemGroup>
<PackageReference Include="AspNetCoreRateLimit.Redis" Version="2.0.0" />
<PackageReference Include="AWSSDK.SimpleEmail" Version="4.0.1.3" />
<PackageReference Include="AWSSDK.SQS" Version="4.0.1.5" />
<PackageReference Include="AWSSDK.SimpleEmail" Version="4.0.2.5" />
<PackageReference Include="AWSSDK.SQS" Version="4.0.2.5" />
<PackageReference Include="Azure.Data.Tables" Version="12.11.0" />
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.4" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.10" />

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

@ -18,16 +18,16 @@ class MjBwIconRow extends BodyComponent {
static defaultAttributes = {};
componentHeadStyle = (breakpoint) => {
headStyle = (breakpoint) => {
return `
@media only screen and (max-width:${breakpoint}): {
".mj-bw-icon-row-text": {
padding-left: "5px !important",
line-height: "20px",
},
".mj-bw-icon-row": {
padding: "10px 15px",
width: "fit-content !important",
@media only screen and (max-width:${breakpoint}) {
.mj-bw-icon-row-text {
padding-left: 5px !important;
line-height: 20px;
}
.mj-bw-icon-row {
padding: 10px 15px;
width: fit-content !important;
}
}
`;

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

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

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

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

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

@ -2,15 +2,14 @@
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Mvc;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
using Xunit;
namespace Bit.Api.Test.AdminConsole.Controllers;
@ -19,7 +18,7 @@ namespace Bit.Api.Test.AdminConsole.Controllers;
[SutProviderCustomize]
public class OrganizationIntegrationControllerTests
{
private OrganizationIntegrationRequestModel _webhookRequestModel = new OrganizationIntegrationRequestModel()
private readonly OrganizationIntegrationRequestModel _webhookRequestModel = new()
{
Configuration = null,
Type = IntegrationType.Webhook
@ -48,13 +47,13 @@ public class OrganizationIntegrationControllerTests
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
sutProvider.GetDependency<IGetOrganizationIntegrationsQuery>()
.GetManyByOrganizationAsync(organizationId)
.Returns(integrations);
var result = await sutProvider.Sut.GetAsync(organizationId);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
await sutProvider.GetDependency<IGetOrganizationIntegrationsQuery>().Received(1)
.GetManyByOrganizationAsync(organizationId);
Assert.Equal(integrations.Count, result.Count);
@ -70,7 +69,7 @@ public class OrganizationIntegrationControllerTests
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
sutProvider.GetDependency<IGetOrganizationIntegrationsQuery>()
.GetManyByOrganizationAsync(organizationId)
.Returns([]);
@ -80,199 +79,133 @@ public class OrganizationIntegrationControllerTests
}
[Theory, BitAutoData]
public async Task CreateAsync_Webhook_AllParamsProvided_Succeeds(
public async Task CreateAsync_AllParamsProvided_Succeeds(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId,
OrganizationIntegration integration)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<ICreateOrganizationIntegrationCommand>()
.CreateAsync(Arg.Any<OrganizationIntegration>())
.Returns(integration);
var response = await sutProvider.Sut.CreateAsync(organizationId, _webhookRequestModel);
await sutProvider.GetDependency<ICreateOrganizationIntegrationCommand>().Received(1)
.CreateAsync(Arg.Is<OrganizationIntegration>(i =>
i.OrganizationId == organizationId &&
i.Type == IntegrationType.Webhook));
Assert.IsType<OrganizationIntegrationResponseModel>(response);
}
[Theory, BitAutoData]
public async Task CreateAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.CreateAsync(Arg.Any<OrganizationIntegration>())
.Returns(callInfo => callInfo.Arg<OrganizationIntegration>());
var response = await sutProvider.Sut.CreateAsync(organizationId, _webhookRequestModel);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.CreateAsync(Arg.Any<OrganizationIntegration>());
Assert.IsType<OrganizationIntegrationResponseModel>(response);
Assert.Equal(IntegrationType.Webhook, response.Type);
}
[Theory, BitAutoData]
public async Task CreateAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(SutProvider<OrganizationIntegrationController> sutProvider, Guid organizationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(false);
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.CreateAsync(organizationId, _webhookRequestModel));
await Assert.ThrowsAsync<NotFoundException>(async () =>
await sutProvider.Sut.CreateAsync(organizationId, _webhookRequestModel));
}
[Theory, BitAutoData]
public async Task DeleteAsync_AllParamsProvided_Succeeds(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration)
Guid integrationId)
{
organizationIntegration.OrganizationId = organizationId;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegration);
await sutProvider.Sut.DeleteAsync(organizationId, organizationIntegration.Id);
await sutProvider.Sut.DeleteAsync(organizationId, integrationId);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.GetByIdAsync(organizationIntegration.Id);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.DeleteAsync(organizationIntegration);
await sutProvider.GetDependency<IDeleteOrganizationIntegrationCommand>().Received(1)
.DeleteAsync(organizationId, integrationId);
}
[Theory, BitAutoData]
[Obsolete("Obsolete")]
public async Task PostDeleteAsync_AllParamsProvided_Succeeds(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration)
{
organizationIntegration.OrganizationId = organizationId;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegration);
await sutProvider.Sut.PostDeleteAsync(organizationId, organizationIntegration.Id);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.GetByIdAsync(organizationIntegration.Id);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.DeleteAsync(organizationIntegration);
}
[Theory, BitAutoData]
public async Task DeleteAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration)
{
organizationIntegration.OrganizationId = Guid.NewGuid();
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.ReturnsNull();
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.DeleteAsync(organizationId, Guid.Empty));
}
[Theory, BitAutoData]
public async Task DeleteAsync_IntegrationDoesNotExist_ThrowsNotFound(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId)
Guid integrationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.ReturnsNull();
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.DeleteAsync(organizationId, Guid.Empty));
await sutProvider.Sut.PostDeleteAsync(organizationId, integrationId);
await sutProvider.GetDependency<IDeleteOrganizationIntegrationCommand>().Received(1)
.DeleteAsync(organizationId, integrationId);
}
[Theory, BitAutoData]
public async Task DeleteAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId)
Guid organizationId,
Guid integrationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(false);
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.DeleteAsync(organizationId, Guid.Empty));
await Assert.ThrowsAsync<NotFoundException>(async () =>
await sutProvider.Sut.DeleteAsync(organizationId, integrationId));
}
[Theory, BitAutoData]
public async Task UpdateAsync_AllParamsProvided_Succeeds(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration)
Guid integrationId,
OrganizationIntegration integration)
{
organizationIntegration.OrganizationId = organizationId;
organizationIntegration.Type = IntegrationType.Webhook;
integration.OrganizationId = organizationId;
integration.Id = integrationId;
integration.Type = IntegrationType.Webhook;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegration);
sutProvider.GetDependency<IUpdateOrganizationIntegrationCommand>()
.UpdateAsync(organizationId, integrationId, Arg.Any<OrganizationIntegration>())
.Returns(integration);
var response = await sutProvider.Sut.UpdateAsync(organizationId, organizationIntegration.Id, _webhookRequestModel);
var response = await sutProvider.Sut.UpdateAsync(organizationId, integrationId, _webhookRequestModel);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.GetByIdAsync(organizationIntegration.Id);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.ReplaceAsync(organizationIntegration);
await sutProvider.GetDependency<IUpdateOrganizationIntegrationCommand>().Received(1)
.UpdateAsync(organizationId, integrationId, Arg.Is<OrganizationIntegration>(i =>
i.OrganizationId == organizationId &&
i.Type == IntegrationType.Webhook));
Assert.IsType<OrganizationIntegrationResponseModel>(response);
Assert.Equal(IntegrationType.Webhook, response.Type);
}
[Theory, BitAutoData]
public async Task UpdateAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration)
{
organizationIntegration.OrganizationId = Guid.NewGuid();
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.ReturnsNull();
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.UpdateAsync(organizationId, Guid.Empty, _webhookRequestModel));
}
[Theory, BitAutoData]
public async Task UpdateAsync_IntegrationDoesNotExist_ThrowsNotFound(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.ReturnsNull();
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.UpdateAsync(organizationId, Guid.Empty, _webhookRequestModel));
}
[Theory, BitAutoData]
public async Task UpdateAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId)
Guid organizationId,
Guid integrationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(false);
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.UpdateAsync(organizationId, Guid.Empty, _webhookRequestModel));
await Assert.ThrowsAsync<NotFoundException>(async () =>
await sutProvider.Sut.UpdateAsync(organizationId, integrationId, _webhookRequestModel));
}
}

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

@ -0,0 +1,161 @@
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using NSubstitute;
using StackExchange.Redis;
using Xunit;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Core.Test.AdminConsole.EventIntegrations;
public class EventIntegrationServiceCollectionExtensionsTests
{
private readonly IServiceCollection _services;
private readonly GlobalSettings _globalSettings;
public EventIntegrationServiceCollectionExtensionsTests()
{
_services = new ServiceCollection();
_globalSettings = CreateGlobalSettings([]);
// Add required infrastructure services
_services.TryAddSingleton(_globalSettings);
_services.TryAddSingleton<IGlobalSettings>(_globalSettings);
_services.AddLogging();
// Mock Redis connection for cache
_services.AddSingleton(Substitute.For<IConnectionMultiplexer>());
// Mock required repository dependencies for commands
_services.TryAddScoped(_ => Substitute.For<IOrganizationIntegrationRepository>());
_services.TryAddScoped(_ => Substitute.For<IOrganizationRepository>());
}
[Fact]
public void AddEventIntegrationsCommandsQueries_RegistersAllServices()
{
_services.AddEventIntegrationsCommandsQueries(_globalSettings);
using var provider = _services.BuildServiceProvider();
var cache = provider.GetRequiredKeyedService<IFusionCache>(EventIntegrationsCacheConstants.CacheName);
Assert.NotNull(cache);
using var scope = provider.CreateScope();
var sp = scope.ServiceProvider;
Assert.NotNull(sp.GetService<ICreateOrganizationIntegrationCommand>());
Assert.NotNull(sp.GetService<IUpdateOrganizationIntegrationCommand>());
Assert.NotNull(sp.GetService<IDeleteOrganizationIntegrationCommand>());
Assert.NotNull(sp.GetService<IGetOrganizationIntegrationsQuery>());
}
[Fact]
public void AddEventIntegrationsCommandsQueries_CommandsQueries_AreRegisteredAsScoped()
{
_services.AddEventIntegrationsCommandsQueries(_globalSettings);
var createIntegrationDescriptor = _services.First(s =>
s.ServiceType == typeof(ICreateOrganizationIntegrationCommand));
Assert.Equal(ServiceLifetime.Scoped, createIntegrationDescriptor.Lifetime);
}
[Fact]
public void AddEventIntegrationsCommandsQueries_CommandsQueries_DifferentInstancesPerScope()
{
_services.AddEventIntegrationsCommandsQueries(_globalSettings);
var provider = _services.BuildServiceProvider();
ICreateOrganizationIntegrationCommand? instance1, instance2, instance3;
using (var scope1 = provider.CreateScope())
{
instance1 = scope1.ServiceProvider.GetService<ICreateOrganizationIntegrationCommand>();
}
using (var scope2 = provider.CreateScope())
{
instance2 = scope2.ServiceProvider.GetService<ICreateOrganizationIntegrationCommand>();
}
using (var scope3 = provider.CreateScope())
{
instance3 = scope3.ServiceProvider.GetService<ICreateOrganizationIntegrationCommand>();
}
Assert.NotNull(instance1);
Assert.NotNull(instance2);
Assert.NotNull(instance3);
Assert.NotSame(instance1, instance2);
Assert.NotSame(instance2, instance3);
Assert.NotSame(instance1, instance3);
}
[Fact]
public void AddEventIntegrationsCommandsQueries_CommandsQueries__SameInstanceWithinScope()
{
_services.AddEventIntegrationsCommandsQueries(_globalSettings);
var provider = _services.BuildServiceProvider();
using var scope = provider.CreateScope();
var instance1 = scope.ServiceProvider.GetService<ICreateOrganizationIntegrationCommand>();
var instance2 = scope.ServiceProvider.GetService<ICreateOrganizationIntegrationCommand>();
Assert.NotNull(instance1);
Assert.NotNull(instance2);
Assert.Same(instance1, instance2);
}
[Fact]
public void AddEventIntegrationsCommandsQueries_MultipleCalls_IsIdempotent()
{
_services.AddEventIntegrationsCommandsQueries(_globalSettings);
_services.AddEventIntegrationsCommandsQueries(_globalSettings);
_services.AddEventIntegrationsCommandsQueries(_globalSettings);
var createConfigCmdDescriptors = _services.Where(s =>
s.ServiceType == typeof(ICreateOrganizationIntegrationCommand)).ToList();
Assert.Single(createConfigCmdDescriptors);
var updateIntegrationCmdDescriptors = _services.Where(s =>
s.ServiceType == typeof(IUpdateOrganizationIntegrationCommand)).ToList();
Assert.Single(updateIntegrationCmdDescriptors);
}
[Fact]
public void AddOrganizationIntegrationCommandsQueries_RegistersAllIntegrationServices()
{
_services.AddOrganizationIntegrationCommandsQueries();
Assert.Contains(_services, s => s.ServiceType == typeof(ICreateOrganizationIntegrationCommand));
Assert.Contains(_services, s => s.ServiceType == typeof(IUpdateOrganizationIntegrationCommand));
Assert.Contains(_services, s => s.ServiceType == typeof(IDeleteOrganizationIntegrationCommand));
Assert.Contains(_services, s => s.ServiceType == typeof(IGetOrganizationIntegrationsQuery));
}
[Fact]
public void AddOrganizationIntegrationCommandsQueries_MultipleCalls_IsIdempotent()
{
_services.AddOrganizationIntegrationCommandsQueries();
_services.AddOrganizationIntegrationCommandsQueries();
_services.AddOrganizationIntegrationCommandsQueries();
var createCmdDescriptors = _services.Where(s =>
s.ServiceType == typeof(ICreateOrganizationIntegrationCommand)).ToList();
Assert.Single(createCmdDescriptors);
}
private static GlobalSettings CreateGlobalSettings(Dictionary<string, string?> data)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(data)
.Build();
var settings = new GlobalSettings();
config.GetSection("GlobalSettings").Bind(settings);
return settings;
}
}

View File

@ -0,0 +1,92 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Core.Test.AdminConsole.EventIntegrations.OrganizationIntegrations;
[SutProviderCustomize]
public class CreateOrganizationIntegrationCommandTests
{
[Theory, BitAutoData]
public async Task CreateAsync_Success_CreatesIntegrationAndInvalidatesCache(
SutProvider<CreateOrganizationIntegrationCommand> sutProvider,
OrganizationIntegration integration)
{
integration.Type = IntegrationType.Webhook;
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetManyByOrganizationAsync(integration.OrganizationId)
.Returns([]);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.CreateAsync(integration)
.Returns(integration);
var result = await sutProvider.Sut.CreateAsync(integration);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.GetManyByOrganizationAsync(integration.OrganizationId);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.CreateAsync(integration);
await sutProvider.GetDependency<IFusionCache>().Received(1)
.RemoveByTagAsync(EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
integration.OrganizationId,
integration.Type));
Assert.Equal(integration, result);
}
[Theory, BitAutoData]
public async Task CreateAsync_DuplicateType_ThrowsBadRequest(
SutProvider<CreateOrganizationIntegrationCommand> sutProvider,
OrganizationIntegration integration,
OrganizationIntegration existingIntegration)
{
integration.Type = IntegrationType.Webhook;
existingIntegration.Type = IntegrationType.Webhook;
existingIntegration.OrganizationId = integration.OrganizationId;
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetManyByOrganizationAsync(integration.OrganizationId)
.Returns([existingIntegration]);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.CreateAsync(integration));
Assert.Contains("An integration of this type already exists", exception.Message);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().DidNotReceive()
.CreateAsync(Arg.Any<OrganizationIntegration>());
await sutProvider.GetDependency<IFusionCache>().DidNotReceive()
.RemoveByTagAsync(Arg.Any<string>());
}
[Theory, BitAutoData]
public async Task CreateAsync_DifferentType_Success(
SutProvider<CreateOrganizationIntegrationCommand> sutProvider,
OrganizationIntegration integration,
OrganizationIntegration existingIntegration)
{
integration.Type = IntegrationType.Webhook;
existingIntegration.Type = IntegrationType.Slack;
existingIntegration.OrganizationId = integration.OrganizationId;
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetManyByOrganizationAsync(integration.OrganizationId)
.Returns([existingIntegration]);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.CreateAsync(integration)
.Returns(integration);
var result = await sutProvider.Sut.CreateAsync(integration);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.CreateAsync(integration);
Assert.Equal(integration, result);
}
}

View File

@ -0,0 +1,86 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Core.Test.AdminConsole.EventIntegrations.OrganizationIntegrations;
[SutProviderCustomize]
public class DeleteOrganizationIntegrationCommandTests
{
[Theory, BitAutoData]
public async Task DeleteAsync_Success_DeletesIntegrationAndInvalidatesCache(
SutProvider<DeleteOrganizationIntegrationCommand> sutProvider,
Guid organizationId,
Guid integrationId,
OrganizationIntegration integration)
{
integration.Id = integrationId;
integration.OrganizationId = organizationId;
integration.Type = IntegrationType.Webhook;
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integrationId)
.Returns(integration);
await sutProvider.Sut.DeleteAsync(organizationId, integrationId);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.GetByIdAsync(integrationId);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.DeleteAsync(integration);
await sutProvider.GetDependency<IFusionCache>().Received(1)
.RemoveByTagAsync(EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
organizationId,
integration.Type));
}
[Theory, BitAutoData]
public async Task DeleteAsync_IntegrationDoesNotExist_ThrowsNotFound(
SutProvider<DeleteOrganizationIntegrationCommand> sutProvider,
Guid organizationId,
Guid integrationId)
{
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integrationId)
.Returns((OrganizationIntegration)null);
await Assert.ThrowsAsync<NotFoundException>(
() => sutProvider.Sut.DeleteAsync(organizationId, integrationId));
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().DidNotReceive()
.DeleteAsync(Arg.Any<OrganizationIntegration>());
await sutProvider.GetDependency<IFusionCache>().DidNotReceive()
.RemoveByTagAsync(Arg.Any<string>());
}
[Theory, BitAutoData]
public async Task DeleteAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound(
SutProvider<DeleteOrganizationIntegrationCommand> sutProvider,
Guid organizationId,
Guid integrationId,
OrganizationIntegration integration)
{
integration.Id = integrationId;
integration.OrganizationId = Guid.NewGuid(); // Different organization
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integrationId)
.Returns(integration);
await Assert.ThrowsAsync<NotFoundException>(
() => sutProvider.Sut.DeleteAsync(organizationId, integrationId));
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().DidNotReceive()
.DeleteAsync(Arg.Any<OrganizationIntegration>());
await sutProvider.GetDependency<IFusionCache>().DidNotReceive()
.RemoveByTagAsync(Arg.Any<string>());
}
}

View File

@ -0,0 +1,44 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations;
using Bit.Core.Repositories;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.AdminConsole.EventIntegrations.OrganizationIntegrations;
[SutProviderCustomize]
public class GetOrganizationIntegrationsQueryTests
{
[Theory, BitAutoData]
public async Task GetManyByOrganizationAsync_CallsRepository(
SutProvider<GetOrganizationIntegrationsQuery> sutProvider,
Guid organizationId,
List<OrganizationIntegration> integrations)
{
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetManyByOrganizationAsync(organizationId)
.Returns(integrations);
var result = await sutProvider.Sut.GetManyByOrganizationAsync(organizationId);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.GetManyByOrganizationAsync(organizationId);
Assert.Equal(integrations.Count, result.Count);
}
[Theory, BitAutoData]
public async Task GetManyByOrganizationAsync_NoIntegrations_ReturnsEmptyList(
SutProvider<GetOrganizationIntegrationsQuery> sutProvider,
Guid organizationId)
{
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetManyByOrganizationAsync(organizationId)
.Returns([]);
var result = await sutProvider.Sut.GetManyByOrganizationAsync(organizationId);
Assert.Empty(result);
}
}

View File

@ -0,0 +1,121 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Core.Test.AdminConsole.EventIntegrations.OrganizationIntegrations;
[SutProviderCustomize]
public class UpdateOrganizationIntegrationCommandTests
{
[Theory, BitAutoData]
public async Task UpdateAsync_Success_UpdatesIntegrationAndInvalidatesCache(
SutProvider<UpdateOrganizationIntegrationCommand> sutProvider,
Guid organizationId,
Guid integrationId,
OrganizationIntegration existingIntegration,
OrganizationIntegration updatedIntegration)
{
existingIntegration.Id = integrationId;
existingIntegration.OrganizationId = organizationId;
existingIntegration.Type = IntegrationType.Webhook;
updatedIntegration.Id = integrationId;
updatedIntegration.OrganizationId = organizationId;
updatedIntegration.Type = IntegrationType.Webhook;
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integrationId)
.Returns(existingIntegration);
var result = await sutProvider.Sut.UpdateAsync(organizationId, integrationId, updatedIntegration);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.GetByIdAsync(integrationId);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.ReplaceAsync(updatedIntegration);
await sutProvider.GetDependency<IFusionCache>().Received(1)
.RemoveByTagAsync(EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
organizationId,
existingIntegration.Type));
Assert.Equal(updatedIntegration, result);
}
[Theory, BitAutoData]
public async Task UpdateAsync_IntegrationDoesNotExist_ThrowsNotFound(
SutProvider<UpdateOrganizationIntegrationCommand> sutProvider,
Guid organizationId,
Guid integrationId,
OrganizationIntegration updatedIntegration)
{
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integrationId)
.Returns((OrganizationIntegration)null);
await Assert.ThrowsAsync<NotFoundException>(
() => sutProvider.Sut.UpdateAsync(organizationId, integrationId, updatedIntegration));
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().DidNotReceive()
.ReplaceAsync(Arg.Any<OrganizationIntegration>());
await sutProvider.GetDependency<IFusionCache>().DidNotReceive()
.RemoveByTagAsync(Arg.Any<string>());
}
[Theory, BitAutoData]
public async Task UpdateAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound(
SutProvider<UpdateOrganizationIntegrationCommand> sutProvider,
Guid organizationId,
Guid integrationId,
OrganizationIntegration existingIntegration,
OrganizationIntegration updatedIntegration)
{
existingIntegration.Id = integrationId;
existingIntegration.OrganizationId = Guid.NewGuid(); // Different organization
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integrationId)
.Returns(existingIntegration);
await Assert.ThrowsAsync<NotFoundException>(
() => sutProvider.Sut.UpdateAsync(organizationId, integrationId, updatedIntegration));
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().DidNotReceive()
.ReplaceAsync(Arg.Any<OrganizationIntegration>());
await sutProvider.GetDependency<IFusionCache>().DidNotReceive()
.RemoveByTagAsync(Arg.Any<string>());
}
[Theory, BitAutoData]
public async Task UpdateAsync_IntegrationIsDifferentType_ThrowsNotFound(
SutProvider<UpdateOrganizationIntegrationCommand> sutProvider,
Guid organizationId,
Guid integrationId,
OrganizationIntegration existingIntegration,
OrganizationIntegration updatedIntegration)
{
existingIntegration.Id = integrationId;
existingIntegration.OrganizationId = organizationId;
existingIntegration.Type = IntegrationType.Webhook;
updatedIntegration.Id = integrationId;
updatedIntegration.OrganizationId = organizationId;
updatedIntegration.Type = IntegrationType.Hec; // Different Type
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integrationId)
.Returns(existingIntegration);
await Assert.ThrowsAsync<NotFoundException>(
() => sutProvider.Sut.UpdateAsync(organizationId, integrationId, updatedIntegration));
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().DidNotReceive()
.ReplaceAsync(Arg.Any<OrganizationIntegration>());
await sutProvider.GetDependency<IFusionCache>().DidNotReceive()
.RemoveByTagAsync(Arg.Any<string>());
}
}

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

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

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