diff --git a/src/Core/OrganizationFeatures/OrganizationCollections/CreateCollectionCommand.cs b/src/Core/OrganizationFeatures/OrganizationCollections/CreateCollectionCommand.cs new file mode 100644 index 0000000000..1cec2f5cc4 --- /dev/null +++ b/src/Core/OrganizationFeatures/OrganizationCollections/CreateCollectionCommand.cs @@ -0,0 +1,70 @@ +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data; +using Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces; +using Bit.Core.Repositories; +using Bit.Core.Services; + +namespace Bit.Core.OrganizationFeatures.OrganizationCollections; + +public class CreateCollectionCommand : ICreateCollectionCommand +{ + private readonly IEventService _eventService; + private readonly IOrganizationRepository _organizationRepository; + private readonly ICollectionRepository _collectionRepository; + + public CreateCollectionCommand( + IEventService eventService, + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository) + { + _eventService = eventService; + _organizationRepository = organizationRepository; + _collectionRepository = collectionRepository; + } + + public async Task CreateAsync(Collection collection, IEnumerable groups = null, + IEnumerable users = null) + { + var org = await _organizationRepository.GetByIdAsync(collection.OrganizationId); + if (org == null) + { + throw new BadRequestException("Organization not found"); + } + + var groupsList = groups?.ToList(); + var usersList = users?.ToList(); + + // Cannot use Manage with ReadOnly/HidePasswords permissions + var invalidAssociations = groupsList?.Where(cas => cas.Manage && (cas.ReadOnly || cas.HidePasswords)); + if (invalidAssociations?.Any() ?? false) + { + throw new BadRequestException("The Manage property is mutually exclusive and cannot be true while the ReadOnly or HidePasswords properties are also true."); + } + + // A collection should always have someone with Can Manage permissions + var groupHasManageAccess = groupsList?.Any(g => g.Manage) ?? false; + var userHasManageAccess = usersList?.Any(u => u.Manage) ?? false; + if (!groupHasManageAccess && !userHasManageAccess && !org.AllowAdminAccessToAllCollectionItems) + { + throw new BadRequestException( + "At least one member or group must have can manage permission."); + } + + // Check max collections limit + if (org.MaxCollections.HasValue) + { + var collectionCount = await _collectionRepository.GetCountByOrganizationIdAsync(org.Id); + if (org.MaxCollections.Value <= collectionCount) + { + throw new BadRequestException("You have reached the maximum number of collections " + + $"({org.MaxCollections.Value}) for this organization."); + } + } + + await _collectionRepository.CreateAsync(collection, org.UseGroups ? groupsList : null, usersList); + await _eventService.LogCollectionEventAsync(collection, Enums.EventType.Collection_Created); + + return collection; + } +} diff --git a/src/Core/OrganizationFeatures/OrganizationCollections/Interfaces/ICreateCollectionCommand.cs b/src/Core/OrganizationFeatures/OrganizationCollections/Interfaces/ICreateCollectionCommand.cs new file mode 100644 index 0000000000..b73afb4d1e --- /dev/null +++ b/src/Core/OrganizationFeatures/OrganizationCollections/Interfaces/ICreateCollectionCommand.cs @@ -0,0 +1,17 @@ +using Bit.Core.Entities; +using Bit.Core.Models.Data; + +namespace Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces; + +public interface ICreateCollectionCommand +{ + /// + /// Creates a new collection. + /// + /// The collection to create. + /// (Optional) The groups that will have access to the collection. + /// (Optional) The users that will have access to the collection. + /// The created collection. + Task CreateAsync(Collection collection, IEnumerable groups = null, + IEnumerable users = null); +} diff --git a/test/Core.Test/OrganizationFeatures/OrganizationCollections/CreateCollectionCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationCollections/CreateCollectionCommandTests.cs new file mode 100644 index 0000000000..d180bad432 --- /dev/null +++ b/test/Core.Test/OrganizationFeatures/OrganizationCollections/CreateCollectionCommandTests.cs @@ -0,0 +1,202 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data; +using Bit.Core.OrganizationFeatures.OrganizationCollections; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Test.AutoFixture; +using Bit.Core.Test.AutoFixture.OrganizationFixtures; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.OrganizationFeatures.OrganizationCollections; + +[SutProviderCustomize] +[OrganizationCustomize] +public class CreateCollectionCommandTests +{ + [Theory, BitAutoData] + public async Task CreateAsync_WithoutGroupsAndUsers_CreatesCollection( + Organization organization, Collection collection, + SutProvider sutProvider) + { + collection.Id = default; + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + var utcNow = DateTime.UtcNow; + + await sutProvider.Sut.CreateAsync(collection, null, null); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync( + collection, + Arg.Is>(l => l == null), + Arg.Is>(l => l == null)); + await sutProvider.GetDependency() + .Received(1) + .LogCollectionEventAsync(collection, EventType.Collection_Created); + Assert.True(collection.CreationDate - utcNow < TimeSpan.FromSeconds(1)); + Assert.True(collection.RevisionDate - utcNow < TimeSpan.FromSeconds(1)); + } + + [Theory, BitAutoData] + public async Task CreateAsync_WithGroupsAndUsers_CreatesCollectionWithGroupsAndUsers( + Organization organization, Collection collection, + [CollectionAccessSelectionCustomize(true)] IEnumerable groups, + IEnumerable users, + SutProvider sutProvider) + { + collection.Id = default; + organization.UseGroups = true; + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + var utcNow = DateTime.UtcNow; + + await sutProvider.Sut.CreateAsync(collection, groups, users); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync( + collection, + Arg.Is>(l => l.Any(i => i.Manage == true)), + Arg.Any>()); + await sutProvider.GetDependency() + .Received(1) + .LogCollectionEventAsync(collection, EventType.Collection_Created); + Assert.True(collection.CreationDate - utcNow < TimeSpan.FromSeconds(1)); + Assert.True(collection.RevisionDate - utcNow < TimeSpan.FromSeconds(1)); + } + + [Theory, BitAutoData] + public async Task CreateAsync_WithOrganizationUseGroupDisabled_CreatesCollectionWithoutGroups( + Organization organization, Collection collection, + [CollectionAccessSelectionCustomize] IEnumerable groups, + [CollectionAccessSelectionCustomize(true)] IEnumerable users, + SutProvider sutProvider) + { + collection.Id = default; + organization.UseGroups = false; + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + var utcNow = DateTime.UtcNow; + + await sutProvider.Sut.CreateAsync(collection, groups, users); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync( + collection, + Arg.Is>(l => l == null), + Arg.Is>(l => l.Any(i => i.Manage == true))); + await sutProvider.GetDependency() + .Received(1) + .LogCollectionEventAsync(collection, EventType.Collection_Created); + Assert.True(collection.CreationDate - utcNow < TimeSpan.FromSeconds(1)); + Assert.True(collection.RevisionDate - utcNow < TimeSpan.FromSeconds(1)); + } + + [Theory, BitAutoData] + public async Task CreateAsync_WithNonExistingOrganizationId_ThrowsBadRequest( + Collection collection, SutProvider sutProvider) + { + collection.Id = default; + var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(collection)); + Assert.Contains("Organization not found", ex.Message); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .CreateAsync(default); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .CreateAsync(default, default, default); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .LogCollectionEventAsync(default, default); + } + + [Theory, BitAutoData] + public async Task CreateAsync_WithoutManageAccess_ThrowsBadRequest( + Organization organization, Collection collection, + [CollectionAccessSelectionCustomize] IEnumerable users, + SutProvider sutProvider) + { + collection.Id = default; + organization.AllowAdminAccessToAllCollectionItems = false; + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(collection, null, users)); + Assert.Contains("At least one member or group must have can manage permission.", ex.Message); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .CreateAsync(default); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .CreateAsync(default, default, default); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .LogCollectionEventAsync(default, default); + } + + [Theory, BitAutoData] + public async Task CreateAsync_WithExceedsOrganizationMaxCollections_ThrowsBadRequest( + Organization organization, Collection collection, + [CollectionAccessSelectionCustomize(true)] IEnumerable users, + SutProvider sutProvider) + { + collection.Id = default; + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + sutProvider.GetDependency() + .GetCountByOrganizationIdAsync(organization.Id) + .Returns(organization.MaxCollections.Value); + + var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(collection, null, users)); + Assert.Equal($@"You have reached the maximum number of collections ({organization.MaxCollections.Value}) for this organization.", ex.Message); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .CreateAsync(default); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .CreateAsync(default, default, default); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .LogCollectionEventAsync(default, default); + } + + [Theory, BitAutoData] + public async Task CreateAsync_WithInvalidManageAssociations_ThrowsBadRequest( + Organization organization, Collection collection, SutProvider sutProvider) + { + collection.Id = default; + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + var invalidGroups = new List + { + new() { Id = Guid.NewGuid(), Manage = true, ReadOnly = true } + }; + + var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(collection, invalidGroups, null)); + Assert.Contains("The Manage property is mutually exclusive and cannot be true while the ReadOnly or HidePasswords properties are also true.", ex.Message); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .CreateAsync(default); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .CreateAsync(default, default, default); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .LogCollectionEventAsync(default, default); + } +}