diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 96de4317f..0eb646902 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -619,9 +619,39 @@ public class CiphersController : Controller var updatedCipher = await GetByIdAsync(id, userId); var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(userId, id, UseFlexibleCollections); + return new CipherDetailsResponseModel(updatedCipher, _globalSettings, collectionCiphers); } + [HttpPut("{id}/collections_v2")] + [HttpPost("{id}/collections_v2")] + public async Task PutCollections_vNext(Guid id, [FromBody] CipherCollectionsRequestModel model) + { + var userId = _userService.GetProperUserId(User).Value; + var cipher = await GetByIdAsync(id, userId); + if (cipher == null || !cipher.OrganizationId.HasValue || + !await _currentContext.OrganizationUser(cipher.OrganizationId.Value)) + { + throw new NotFoundException(); + } + + await _cipherService.SaveCollectionsAsync(cipher, + model.CollectionIds.Select(c => new Guid(c)), userId, false); + + var updatedCipher = await GetByIdAsync(id, userId); + var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(userId, id, UseFlexibleCollections); + // If a user removes the last Can Manage access of a cipher, the "updatedCipher" will return null + // We will be returning an "Unavailable" property so the client knows the user can no longer access this + var response = new OptionalCipherDetailsResponseModel() + { + Unavailable = updatedCipher is null, + Cipher = updatedCipher is null + ? null + : new CipherDetailsResponseModel(updatedCipher, _globalSettings, collectionCiphers) + }; + return response; + } + [HttpPut("{id}/collections-admin")] [HttpPost("{id}/collections-admin")] public async Task PutCollectionsAdmin(string id, [FromBody] CipherCollectionsRequestModel model) diff --git a/src/Api/Vault/Models/Response/OptionalCipherDetailsResponseModel.cs b/src/Api/Vault/Models/Response/OptionalCipherDetailsResponseModel.cs new file mode 100644 index 000000000..018185e04 --- /dev/null +++ b/src/Api/Vault/Models/Response/OptionalCipherDetailsResponseModel.cs @@ -0,0 +1,13 @@ +using Bit.Api.Vault.Models.Response; +using Bit.Core.Models.Api; + +public class OptionalCipherDetailsResponseModel : ResponseModel +{ + public bool Unavailable { get; set; } + + public CipherDetailsResponseModel? Cipher { get; set; } + + public OptionalCipherDetailsResponseModel() + : base("optionalCipherDetails") + { } +} diff --git a/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs b/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs index 14f42db72..13f172af6 100644 --- a/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs +++ b/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs @@ -3,9 +3,11 @@ using Bit.Api.Vault.Controllers; using Bit.Api.Vault.Models.Request; using Bit.Core; using Bit.Core.Context; +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations; +using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Models.Data; @@ -14,7 +16,9 @@ using Bit.Core.Vault.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; +using NSubstitute.ReturnsExtensions; using Xunit; +using CipherType = Bit.Core.Vault.Enums.CipherType; namespace Bit.Api.Test.Controllers; @@ -50,6 +54,92 @@ public class CiphersControllerTests Assert.Equal(isFavorite, result.Favorite); } + [Theory, BitAutoData] + public async Task PutCollections_vNextShouldThrowExceptionWhenCipherIsNullOrNoOrgValue(Guid id, CipherCollectionsRequestModel model, Guid userId, + SutProvider sutProvider) + { + sutProvider.GetDependency().GetProperUserId(default).Returns(userId); + sutProvider.GetDependency().OrganizationUser(Guid.NewGuid()).Returns(false); + sutProvider.GetDependency().GetByIdAsync(id, userId, true).ReturnsNull(); + + var requestAction = async () => await sutProvider.Sut.PutCollections_vNext(id, model); + + await Assert.ThrowsAsync(requestAction); + } + + [Theory, BitAutoData] + public async Task PutCollections_vNextShouldSaveUpdatedCipher(Guid id, CipherCollectionsRequestModel model, Guid userId, SutProvider sutProvider) + { + SetupUserAndOrgMocks(id, userId, sutProvider); + var cipherDetails = CreateCipherDetailsMock(id, userId); + sutProvider.GetDependency().GetByIdAsync(id, userId, true).ReturnsForAnyArgs(cipherDetails); + + sutProvider.GetDependency().GetManyByUserIdCipherIdAsync(userId, id, Arg.Any()).Returns((ICollection)new List()); + var cipherService = sutProvider.GetDependency(); + + await sutProvider.Sut.PutCollections_vNext(id, model); + + await cipherService.ReceivedWithAnyArgs().SaveCollectionsAsync(default, default, default, default); + } + + [Theory, BitAutoData] + public async Task PutCollections_vNextReturnOptionalDetailsCipherUnavailableFalse(Guid id, CipherCollectionsRequestModel model, Guid userId, SutProvider sutProvider) + { + SetupUserAndOrgMocks(id, userId, sutProvider); + var cipherDetails = CreateCipherDetailsMock(id, userId); + sutProvider.GetDependency().GetByIdAsync(id, userId, true).ReturnsForAnyArgs(cipherDetails); + + sutProvider.GetDependency().GetManyByUserIdCipherIdAsync(userId, id, Arg.Any()).Returns((ICollection)new List()); + + var result = await sutProvider.Sut.PutCollections_vNext(id, model); + + Assert.IsType(result); + Assert.False(result.Unavailable); + } + + [Theory, BitAutoData] + public async Task PutCollections_vNextReturnOptionalDetailsCipherUnavailableTrue(Guid id, CipherCollectionsRequestModel model, Guid userId, SutProvider sutProvider) + { + SetupUserAndOrgMocks(id, userId, sutProvider); + var cipherDetails = CreateCipherDetailsMock(id, userId); + sutProvider.GetDependency().GetByIdAsync(id, userId, true).ReturnsForAnyArgs(cipherDetails, [(CipherDetails)null]); + + sutProvider.GetDependency().GetManyByUserIdCipherIdAsync(userId, id, Arg.Any()).Returns((ICollection)new List()); + + var result = await sutProvider.Sut.PutCollections_vNext(id, model); + + Assert.IsType(result); + Assert.True(result.Unavailable); + } + + private void SetupUserAndOrgMocks(Guid id, Guid userId, SutProvider sutProvider) + { + sutProvider.GetDependency().GetProperUserId(default).ReturnsForAnyArgs(userId); + sutProvider.GetDependency().OrganizationUser(default).ReturnsForAnyArgs(true); + sutProvider.GetDependency().GetManyByUserIdCipherIdAsync(userId, id, Arg.Any()).Returns(new List()); + } + + private CipherDetails CreateCipherDetailsMock(Guid id, Guid userId) + { + return new CipherDetails + { + Id = id, + UserId = userId, + OrganizationId = Guid.NewGuid(), + Type = CipherType.Login, + Data = @" + { + ""Uris"": [ + { + ""Uri"": ""https://bitwarden.com"" + } + ], + ""Username"": ""testuser"", + ""Password"": ""securepassword123"" + }" + }; + } + [Theory] [BitAutoData(OrganizationUserType.Admin, true, true)] [BitAutoData(OrganizationUserType.Owner, true, true)]