diff --git a/src/Api/Controllers/CiphersController.cs b/src/Api/Controllers/CiphersController.cs index 98d3cf998c..fd5000d5d2 100644 --- a/src/Api/Controllers/CiphersController.cs +++ b/src/Api/Controllers/CiphersController.cs @@ -113,7 +113,7 @@ namespace Bit.Api.Controllers throw new NotFoundException(); } - await _cipherService.SaveDetailsAsync(cipher, userId, null, cipher.OrganizationId.HasValue); + await _cipherService.SaveDetailsAsync(cipher, userId, model.LastKnownRevisionDate, null, cipher.OrganizationId.HasValue); var response = new CipherResponseModel(cipher, _globalSettings); return response; } @@ -128,7 +128,7 @@ namespace Bit.Api.Controllers throw new NotFoundException(); } - await _cipherService.SaveDetailsAsync(cipher, userId, model.CollectionIds, cipher.OrganizationId.HasValue); + await _cipherService.SaveDetailsAsync(cipher, userId, model.Cipher.LastKnownRevisionDate, model.CollectionIds, cipher.OrganizationId.HasValue); var response = new CipherResponseModel(cipher, _globalSettings); return response; } @@ -143,7 +143,7 @@ namespace Bit.Api.Controllers } var userId = _userService.GetProperUserId(User).Value; - await _cipherService.SaveAsync(cipher, userId, model.CollectionIds, true, false); + await _cipherService.SaveAsync(cipher, userId, model.Cipher.LastKnownRevisionDate, model.CollectionIds, true, false); var response = new CipherMiniResponseModel(cipher, _globalSettings, false); return response; @@ -168,7 +168,7 @@ namespace Bit.Api.Controllers "then try again."); } - await _cipherService.SaveDetailsAsync(model.ToCipherDetails(cipher), userId); + await _cipherService.SaveDetailsAsync(model.ToCipherDetails(cipher), userId, model.LastKnownRevisionDate); var response = new CipherResponseModel(cipher, _globalSettings); return response; @@ -188,7 +188,7 @@ namespace Bit.Api.Controllers // object cannot be a descendant of CipherDetails, so let's clone it. var cipherClone = CoreHelpers.CloneObject(model.ToCipher(cipher)); - await _cipherService.SaveAsync(cipherClone, userId, null, true, false); + await _cipherService.SaveAsync(cipherClone, userId, model.LastKnownRevisionDate, null, true, false); var response = new CipherMiniResponseModel(cipherClone, _globalSettings, cipher.OrganizationUseTotp); return response; @@ -277,8 +277,8 @@ namespace Bit.Api.Controllers } var original = CoreHelpers.CloneObject(cipher); - await _cipherService.ShareAsync(original, model.Cipher.ToCipher(cipher), - new Guid(model.Cipher.OrganizationId), model.CollectionIds.Select(c => new Guid(c)), userId); + await _cipherService.ShareAsync(original, model.Cipher.ToCipher(cipher), new Guid(model.Cipher.OrganizationId), + model.CollectionIds.Select(c => new Guid(c)), userId, model.Cipher.LastKnownRevisionDate); var sharedCipher = await _cipherRepository.GetByIdAsync(cipherId, userId); var response = new CipherResponseModel(sharedCipher, _globalSettings); @@ -503,7 +503,7 @@ namespace Bit.Api.Controllers var ciphers = await _cipherRepository.GetManyByUserIdAsync(userId, false); var ciphersDict = ciphers.ToDictionary(c => c.Id); - var shareCiphers = new List(); + var shareCiphers = new List<(Cipher, DateTime?)>(); foreach (var cipher in model.Ciphers) { if (!ciphersDict.ContainsKey(cipher.Id.Value)) @@ -511,7 +511,7 @@ namespace Bit.Api.Controllers throw new BadRequestException("Trying to share ciphers that you do not own."); } - shareCiphers.Add(cipher.ToCipher(ciphersDict[cipher.Id.Value])); + shareCiphers.Add((cipher.ToCipher(ciphersDict[cipher.Id.Value]), cipher.LastKnownRevisionDate)); } await _cipherService.ShareManyAsync(shareCiphers, organizationId, diff --git a/src/Core/Models/Api/Request/CipherRequestModel.cs b/src/Core/Models/Api/Request/CipherRequestModel.cs index 38b93a2db4..d1db599e71 100644 --- a/src/Core/Models/Api/Request/CipherRequestModel.cs +++ b/src/Core/Models/Api/Request/CipherRequestModel.cs @@ -38,6 +38,7 @@ namespace Bit.Core.Models.Api public CipherCardModel Card { get; set; } public CipherIdentityModel Identity { get; set; } public CipherSecureNoteModel SecureNote { get; set; } + public DateTime? LastKnownRevisionDate { get; set; } = null; public CipherDetails ToCipherDetails(Guid userId, bool allowOrgIdSet = true) { diff --git a/src/Core/Services/ICipherService.cs b/src/Core/Services/ICipherService.cs index ebf749eb11..309b2c6d23 100644 --- a/src/Core/Services/ICipherService.cs +++ b/src/Core/Services/ICipherService.cs @@ -9,10 +9,10 @@ namespace Bit.Core.Services { public interface ICipherService { - Task SaveAsync(Cipher cipher, Guid savingUserId, IEnumerable collectionIds = null, + Task SaveAsync(Cipher cipher, Guid savingUserId, DateTime? lastKnownRevisionDate, IEnumerable collectionIds = null, bool skipPermissionCheck = false, bool limitCollectionScope = true); - Task SaveDetailsAsync(CipherDetails cipher, Guid savingUserId, IEnumerable collectionIds = null, - bool skipPermissionCheck = false); + Task SaveDetailsAsync(CipherDetails cipher, Guid savingUserId, DateTime? lastKnownRevisionDate, + IEnumerable collectionIds = null, bool skipPermissionCheck = false); Task CreateAttachmentAsync(Cipher cipher, Stream stream, string fileName, string key, long requestLength, Guid savingUserId, bool orgAdmin = false); Task CreateAttachmentShareAsync(Cipher cipher, Stream stream, long requestLength, string attachmentId, @@ -25,9 +25,9 @@ namespace Bit.Core.Services Task SaveFolderAsync(Folder folder); Task DeleteFolderAsync(Folder folder); Task ShareAsync(Cipher originalCipher, Cipher cipher, Guid organizationId, IEnumerable collectionIds, - Guid userId); - Task ShareManyAsync(IEnumerable ciphers, Guid organizationId, IEnumerable collectionIds, - Guid sharingUserId); + Guid userId, DateTime? lastKnownRevisionDate); + Task ShareManyAsync(IEnumerable<(Cipher cipher, DateTime? lastKnownRevisionDate)> ciphers, Guid organizationId, + IEnumerable collectionIds, Guid sharingUserId); Task SaveCollectionsAsync(Cipher cipher, IEnumerable collectionIds, Guid savingUserId, bool orgAdmin); Task ImportCiphersAsync(List folders, List ciphers, IEnumerable> folderRelationships); diff --git a/src/Core/Services/Implementations/CipherService.cs b/src/Core/Services/Implementations/CipherService.cs index 6529b2e7a6..ce8319364a 100644 --- a/src/Core/Services/Implementations/CipherService.cs +++ b/src/Core/Services/Implementations/CipherService.cs @@ -57,8 +57,8 @@ namespace Bit.Core.Services _globalSettings = globalSettings; } - public async Task SaveAsync(Cipher cipher, Guid savingUserId, IEnumerable collectionIds = null, - bool skipPermissionCheck = false, bool limitCollectionScope = true) + public async Task SaveAsync(Cipher cipher, Guid savingUserId, DateTime? lastKnownRevisionDate, + IEnumerable collectionIds = null, bool skipPermissionCheck = false, bool limitCollectionScope = true) { if (!skipPermissionCheck && !(await UserCanEditAsync(cipher, savingUserId))) { @@ -91,6 +91,7 @@ namespace Bit.Core.Services { throw new ArgumentException("Cannot create cipher with collection ids at the same time."); } + ValidateCipherLastKnownRevisionDateAsync(cipher, lastKnownRevisionDate); cipher.RevisionDate = DateTime.UtcNow; await _cipherRepository.ReplaceAsync(cipher); await _eventService.LogCipherEventAsync(cipher, Enums.EventType.Cipher_Updated); @@ -100,7 +101,7 @@ namespace Bit.Core.Services } } - public async Task SaveDetailsAsync(CipherDetails cipher, Guid savingUserId, + public async Task SaveDetailsAsync(CipherDetails cipher, Guid savingUserId, DateTime? lastKnownRevisionDate, IEnumerable collectionIds = null, bool skipPermissionCheck = false) { if (!skipPermissionCheck && !(await UserCanEditAsync(cipher, savingUserId))) @@ -136,6 +137,7 @@ namespace Bit.Core.Services { throw new ArgumentException("Cannot create cipher with collection ids at the same time."); } + ValidateCipherLastKnownRevisionDateAsync(cipher, lastKnownRevisionDate); cipher.RevisionDate = DateTime.UtcNow; await _cipherRepository.ReplaceAsync(cipher); await _eventService.LogCipherEventAsync(cipher, Enums.EventType.Cipher_Updated); @@ -394,7 +396,7 @@ namespace Bit.Core.Services } public async Task ShareAsync(Cipher originalCipher, Cipher cipher, Guid organizationId, - IEnumerable collectionIds, Guid sharingUserId) + IEnumerable collectionIds, Guid sharingUserId, DateTime? lastKnownRevisionDate) { var attachments = cipher.GetAttachments(); var hasAttachments = attachments?.Any() ?? false; @@ -431,6 +433,8 @@ namespace Bit.Core.Services throw new BadRequestException("Not enough storage available for this organization."); } + ValidateCipherLastKnownRevisionDateAsync(cipher, lastKnownRevisionDate); + // Sproc will not save this UserId on the cipher. It is used limit scope of the collectionIds. cipher.UserId = sharingUserId; cipher.OrganizationId = organizationId; @@ -490,11 +494,11 @@ namespace Bit.Core.Services } } - public async Task ShareManyAsync(IEnumerable ciphers, Guid organizationId, - IEnumerable collectionIds, Guid sharingUserId) + public async Task ShareManyAsync(IEnumerable<(Cipher cipher, DateTime? lastKnownRevisionDate)> cipherInfos, + Guid organizationId, IEnumerable collectionIds, Guid sharingUserId) { var cipherIds = new List(); - foreach (var cipher in ciphers) + foreach (var (cipher, lastKnownRevisionDate) in cipherInfos) { if (cipher.Id == default(Guid)) { @@ -511,18 +515,20 @@ namespace Bit.Core.Services throw new BadRequestException("One or more ciphers do not belong to you."); } + ValidateCipherLastKnownRevisionDateAsync(cipher, lastKnownRevisionDate); + cipher.UserId = null; cipher.OrganizationId = organizationId; cipher.RevisionDate = DateTime.UtcNow; cipherIds.Add(cipher.Id); } - await _cipherRepository.UpdateCiphersAsync(sharingUserId, ciphers); + await _cipherRepository.UpdateCiphersAsync(sharingUserId, cipherInfos.Select(c => c.cipher)); await _collectionCipherRepository.UpdateCollectionsForCiphersAsync(cipherIds, sharingUserId, organizationId, collectionIds); - var events = ciphers.Select(c => - new Tuple(c, EventType.Cipher_Shared, null)); + var events = cipherInfos.Select(c => + new Tuple(c.cipher, EventType.Cipher_Shared, null)); foreach (var eventsBatch in events.Batch(100)) { await _eventService.LogCipherEventsAsync(eventsBatch); @@ -790,5 +796,20 @@ namespace Bit.Core.Services return await _cipherRepository.GetCanEditByIdAsync(userId, cipher.Id); } + + private void ValidateCipherLastKnownRevisionDateAsync(Cipher cipher, DateTime? lastKnownRevisionDate) + { + if (cipher.Id == default || !lastKnownRevisionDate.HasValue) + { + return; + } + + if ((cipher.RevisionDate - lastKnownRevisionDate.Value).Duration() > TimeSpan.FromSeconds(1)) + { + throw new BadRequestException( + "The cipher you are updating is out of date. Please save your work, sync your vault, and try again." + ); + } + } } } diff --git a/test/Core.Test/AutoFixture/Attributes/CustomAutoDataAttribute.cs b/test/Core.Test/AutoFixture/Attributes/CustomAutoDataAttribute.cs new file mode 100644 index 0000000000..66a8c17412 --- /dev/null +++ b/test/Core.Test/AutoFixture/Attributes/CustomAutoDataAttribute.cs @@ -0,0 +1,25 @@ +using System; +using System.Linq; +using AutoFixture; +using AutoFixture.Xunit2; + +namespace Bit.Core.Test.AutoFixture.Attributes +{ + internal class CustomAutoDataAttribute : AutoDataAttribute + { + public CustomAutoDataAttribute(params Type[] iCustomizationTypes) : this(iCustomizationTypes + .Select(t => (ICustomization)Activator.CreateInstance(t)).ToArray()) + { } + + public CustomAutoDataAttribute(params ICustomization[] customizations) : base(() => + { + var fixture = new Fixture(); + foreach (var customization in customizations) + { + fixture.Customize(customization); + } + return fixture; + }) + { } + } +} diff --git a/test/Core.Test/AutoFixture/Attributes/InlineCustomAutoDataAttribute.cs b/test/Core.Test/AutoFixture/Attributes/InlineCustomAutoDataAttribute.cs new file mode 100644 index 0000000000..ac1c22a65c --- /dev/null +++ b/test/Core.Test/AutoFixture/Attributes/InlineCustomAutoDataAttribute.cs @@ -0,0 +1,23 @@ +using System; +using Xunit; +using Xunit.Sdk; +using AutoFixture.Xunit2; +using AutoFixture; + +namespace Bit.Core.Test.AutoFixture.Attributes +{ + internal class InlineCustomAutoDataAttribute : CompositeDataAttribute + { + public InlineCustomAutoDataAttribute(Type[] iCustomizationTypes, params object[] values) : base(new DataAttribute[] { + new InlineDataAttribute(values), + new CustomAutoDataAttribute(iCustomizationTypes) + }) + { } + + public InlineCustomAutoDataAttribute(ICustomization[] customizations, params object[] values) : base(new DataAttribute[] { + new InlineDataAttribute(values), + new CustomAutoDataAttribute(customizations) + }) + { } + } +} diff --git a/test/Core.Test/AutoFixture/CipherFixtures.cs b/test/Core.Test/AutoFixture/CipherFixtures.cs new file mode 100644 index 0000000000..84bf37c0de --- /dev/null +++ b/test/Core.Test/AutoFixture/CipherFixtures.cs @@ -0,0 +1,57 @@ +using System; +using AutoFixture; +using Bit.Core.Models.Table; +using Bit.Core.Test.AutoFixture.Attributes; +using Core.Models.Data; + +namespace Bit.Core.Test.AutoFixture.CipherFixtures +{ + internal class OrganizationCipher : ICustomization + { + public Guid? OrganizationId { get; set; } + public void Customize(IFixture fixture) + { + fixture.Customize(composer => composer + .With(c => c.OrganizationId, OrganizationId ?? Guid.NewGuid()) + .Without(c => c.UserId)); + fixture.Customize(composer => composer + .With(c => c.OrganizationId, Guid.NewGuid()) + .Without(c => c.UserId)); + } + } + + internal class UserCipher : ICustomization + { + public Guid? UserId { get; set; } + public void Customize(IFixture fixture) + { + fixture.Customize(composer => composer + .With(c => c.UserId, UserId ?? Guid.NewGuid()) + .Without(c => c.OrganizationId)); + fixture.Customize(composer => composer + .With(c => c.UserId, Guid.NewGuid()) + .Without(c => c.OrganizationId)); + } + } + + internal class UserCipherAutoDataAttribute : CustomAutoDataAttribute + { + public UserCipherAutoDataAttribute(string userId = null) : base(new SutProviderCustomization(), + new UserCipher { UserId = userId == null ? (Guid?)null : new Guid(userId) }) + { } + } + internal class InlineUserCipherAutoDataAttribute : InlineCustomAutoDataAttribute + { + public InlineUserCipherAutoDataAttribute(params object[] values) : base(new[] { typeof(SutProviderCustomization), + typeof(UserCipher) }, values) + { } + } + + internal class InlineKnownUserCipherAutoDataAttribute : InlineCustomAutoDataAttribute + { + public InlineKnownUserCipherAutoDataAttribute(string userId, params object[] values) : base(new ICustomization[] + { new SutProviderCustomization(), new UserCipher { UserId = new Guid(userId) } }, values) + { } + +} +} diff --git a/test/Core.Test/AutoFixture/FixtureExtensions.cs b/test/Core.Test/AutoFixture/FixtureExtensions.cs new file mode 100644 index 0000000000..10021fbee1 --- /dev/null +++ b/test/Core.Test/AutoFixture/FixtureExtensions.cs @@ -0,0 +1,11 @@ +using AutoFixture; +using AutoFixture.AutoNSubstitute; + +namespace Bit.Core.Test.AutoFixture +{ + public static class FixtureExtensions + { + public static IFixture WithAutoNSubstitutions(this IFixture fixture) + => fixture.Customize(new AutoNSubstituteCustomization()); + } +} diff --git a/test/Core.Test/AutoFixture/ISutProvider.cs b/test/Core.Test/AutoFixture/ISutProvider.cs new file mode 100644 index 0000000000..3a22bf4895 --- /dev/null +++ b/test/Core.Test/AutoFixture/ISutProvider.cs @@ -0,0 +1,10 @@ +using System; + +namespace Bit.Core.Test.AutoFixture +{ + public interface ISutProvider + { + Type SutType { get; } + ISutProvider Create(); + } +} diff --git a/test/Core.Test/AutoFixture/SutProvider.cs b/test/Core.Test/AutoFixture/SutProvider.cs new file mode 100644 index 0000000000..220777c44d --- /dev/null +++ b/test/Core.Test/AutoFixture/SutProvider.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using AutoFixture; +using AutoFixture.Kernel; +using System.Reflection; +using System.Linq; + +namespace Bit.Core.Test.AutoFixture +{ + public class SutProvider : ISutProvider + { + private Dictionary> _dependencies; + private readonly IFixture _fixture; + private readonly ConstructorParameterRelay _constructorParameterRelay; + + public TSut Sut { get; private set; } + public Type SutType => typeof(TSut); + + public SutProvider() + { + _dependencies = new Dictionary>(); + _fixture = new Fixture().WithAutoNSubstitutions(); + _constructorParameterRelay = new ConstructorParameterRelay(this, _fixture); + _fixture.Customizations.Add(_constructorParameterRelay); + } + + public SutProvider SetDependency(T dependency, string parameterName = "") + => SetDependency(typeof(T), dependency, parameterName); + public SutProvider SetDependency(Type dependencyType, object dependency, string parameterName = "") + { + if (_dependencies.ContainsKey(dependencyType)) + { + _dependencies[dependencyType][parameterName] = dependency; + } + else + { + _dependencies[dependencyType] = new Dictionary { { parameterName, dependency } }; + } + + return this; + } + + public T GetDependency(string parameterName = "") => (T)GetDependency(typeof(T), parameterName); + public object GetDependency(Type dependencyType, string parameterName = "") + { + if (DependencyIsSet(dependencyType, parameterName)) + { + return _dependencies[dependencyType][parameterName]; + } + else if (_dependencies.ContainsKey(dependencyType)) + { + var knownDependencies = _dependencies[dependencyType]; + if (knownDependencies.Values.Count == 1) + { + return _dependencies[dependencyType].Values.Single(); + } + else + { + throw new ArgumentException(string.Concat($"Dependency of type {dependencyType.Name} and name ", + $"{parameterName} does not exist. Available dependency names are: ", + string.Join(", ", knownDependencies.Keys))); + } + } + else + { + throw new ArgumentException($"Dependency of type {dependencyType.Name} and name {parameterName} has not been set."); + } + } + + public void Reset() + { + _dependencies = new Dictionary>(); + Sut = default; + } + + ISutProvider ISutProvider.Create() => Create(); + public SutProvider Create() + { + Sut = _fixture.Create(); + return this; + } + + private bool DependencyIsSet(Type dependencyType, string parameterName = "") + => _dependencies.ContainsKey(dependencyType) && _dependencies[dependencyType].ContainsKey(parameterName); + + private object GetDefault(Type type) => type.IsValueType ? Activator.CreateInstance(type) : null; + + private class ConstructorParameterRelay : ISpecimenBuilder + { + private readonly SutProvider _sutProvider; + private readonly IFixture _fixture; + + public ConstructorParameterRelay(SutProvider sutProvider, IFixture fixture) + { + _sutProvider = sutProvider; + _fixture = fixture; + } + + public object Create(object request, ISpecimenContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + if (!(request is ParameterInfo parameterInfo)) + { + return new NoSpecimen(); + } + if (parameterInfo.Member.DeclaringType != typeof(T) || + parameterInfo.Member.MemberType != MemberTypes.Constructor) + { + return new NoSpecimen(); + } + + if (_sutProvider.DependencyIsSet(parameterInfo.ParameterType, parameterInfo.Name)) + { + return _sutProvider.GetDependency(parameterInfo.ParameterType, parameterInfo.Name); + } + + + // This is the equivalent of _fixture.Create, but no overload for + // Create(Type type) exists. + var dependency = new SpecimenContext(_fixture).Resolve(new SeededRequest(parameterInfo.ParameterType, + _sutProvider.GetDefault(parameterInfo.ParameterType))); + _sutProvider.SetDependency(parameterInfo.ParameterType, dependency, parameterInfo.Name); + return dependency; + } + } + } +} diff --git a/test/Core.Test/AutoFixture/SutProviderCustomization.cs b/test/Core.Test/AutoFixture/SutProviderCustomization.cs new file mode 100644 index 0000000000..ecafba5b3d --- /dev/null +++ b/test/Core.Test/AutoFixture/SutProviderCustomization.cs @@ -0,0 +1,32 @@ +using System; +using AutoFixture; +using AutoFixture.Kernel; + +namespace Bit.Core.Test.AutoFixture +{ + public class SutProviderCustomization : ICustomization, ISpecimenBuilder + { + public object Create(object request, ISpecimenContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + if (!(request is Type typeRequest)) + { + return new NoSpecimen(); + } + if (!typeof(ISutProvider).IsAssignableFrom(typeRequest)) + { + return new NoSpecimen(); + } + + return ((ISutProvider)Activator.CreateInstance(typeRequest)).Create(); + } + + public void Customize(IFixture fixture) + { + fixture.Customizations.Add(this); + } + } +} diff --git a/test/Core.Test/Core.Test.csproj b/test/Core.Test/Core.Test.csproj index 390880c953..f91a607cd2 100644 --- a/test/Core.Test/Core.Test.csproj +++ b/test/Core.Test/Core.Test.csproj @@ -1,4 +1,4 @@ - + netcoreapp3.1 @@ -14,6 +14,8 @@ all runtime; build; native; contentfiles; analyzers + + diff --git a/test/Core.Test/Services/CipherServiceTests.cs b/test/Core.Test/Services/CipherServiceTests.cs index e1f949db7c..b3d49546cd 100644 --- a/test/Core.Test/Services/CipherServiceTests.cs +++ b/test/Core.Test/Services/CipherServiceTests.cs @@ -1,65 +1,120 @@ using System; +using System.Threading.Tasks; using Bit.Core.Repositories; using Bit.Core.Services; using NSubstitute; using Xunit; +using Bit.Core.Exceptions; +using Bit.Core.Models.Table; +using Core.Models.Data; +using Bit.Core.Test.AutoFixture.CipherFixtures; +using System.Collections.Generic; +using Bit.Core.Test.AutoFixture; +using System.Linq; +using Castle.Core.Internal; namespace Bit.Core.Test.Services { public class CipherServiceTests { - private readonly CipherService _sut; - - private readonly ICipherRepository _cipherRepository; - private readonly IFolderRepository _folderRepository; - private readonly ICollectionRepository _collectionRepository; - private readonly IUserRepository _userRepository; - private readonly IOrganizationRepository _organizationRepository; - private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly ICollectionCipherRepository _collectionCipherRepository; - private readonly IPushNotificationService _pushService; - private readonly IAttachmentStorageService _attachmentStorageService; - private readonly IEventService _eventService; - private readonly IUserService _userService; - private readonly GlobalSettings _globalSettings; - - public CipherServiceTests() + [Theory, UserCipherAutoData] + public async Task SaveAsync_WrongRevisionDate_Throws(SutProvider sutProvider, Cipher cipher) { - _cipherRepository = Substitute.For(); - _folderRepository = Substitute.For(); - _collectionRepository = Substitute.For(); - _userRepository = Substitute.For(); - _organizationRepository = Substitute.For(); - _organizationUserRepository = Substitute.For(); - _collectionCipherRepository = Substitute.For(); - _pushService = Substitute.For(); - _attachmentStorageService = Substitute.For(); - _eventService = Substitute.For(); - _userService = Substitute.For(); - _globalSettings = new GlobalSettings(); + var lastKnownRevisionDate = cipher.RevisionDate.AddDays(-1); - _sut = new CipherService( - _cipherRepository, - _folderRepository, - _collectionRepository, - _userRepository, - _organizationRepository, - _organizationUserRepository, - _collectionCipherRepository, - _pushService, - _attachmentStorageService, - _eventService, - _userService, - _globalSettings - ); + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.SaveAsync(cipher, cipher.UserId.Value, lastKnownRevisionDate)); + Assert.Contains("out of date", exception.Message); } - // Remove this test when we add actual tests. It only proves that - // we've properly constructed the system under test. - [Fact] - public void ServiceExists() + [Theory, UserCipherAutoData] + public async Task SaveDetailsAsync_WrongRevisionDate_Throws(SutProvider sutProvider, + CipherDetails cipherDetails) { - Assert.NotNull(_sut); + var lastKnownRevisionDate = cipherDetails.RevisionDate.AddDays(-1); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.SaveDetailsAsync(cipherDetails, cipherDetails.UserId.Value, lastKnownRevisionDate)); + Assert.Contains("out of date", exception.Message); + } + + [Theory, UserCipherAutoData] + public async Task ShareAsync_WrongRevisionDate_Throws(SutProvider sutProvider, Cipher cipher, + Organization organization, List collectionIds) + { + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + var lastKnownRevisionDate = cipher.RevisionDate.AddDays(-1); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.ShareAsync(cipher, cipher, organization.Id, collectionIds, cipher.UserId.Value, + lastKnownRevisionDate)); + Assert.Contains("out of date", exception.Message); + } + + [Theory, UserCipherAutoData("99ab4f6c-44f8-4ff5-be7a-75c37c33c69e")] + public async Task ShareManyAsync_WrongRevisionDate_Throws(SutProvider sutProvider, + IEnumerable ciphers, Guid organizationId, List collectionIds) + { + var cipherInfos = ciphers.Select(c => (c, (DateTime?)c.RevisionDate.AddDays(-1))); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.ShareManyAsync(cipherInfos, organizationId, collectionIds, ciphers.First().UserId.Value)); + Assert.Contains("out of date", exception.Message); + } + + [Theory] + [InlineUserCipherAutoData("")] + [InlineUserCipherAutoData("Correct Time")] + public async Task SaveAsync_CorrectRevisionDate_Passes(string revisionDateString, + SutProvider sutProvider, Cipher cipher) + { + var lastKnownRevisionDate = string.IsNullOrEmpty(revisionDateString) ? (DateTime?)null : cipher.RevisionDate; + + await sutProvider.Sut.SaveAsync(cipher, cipher.UserId.Value, lastKnownRevisionDate); + await sutProvider.GetDependency().Received(1).ReplaceAsync(cipher); + } + + [Theory] + [InlineUserCipherAutoData("")] + [InlineUserCipherAutoData("Correct Time")] + public async Task SaveDetailsAsync_CorrectRevisionDate_Passes(string revisionDateString, + SutProvider sutProvider, CipherDetails cipherDetails) + { + var lastKnownRevisionDate = string.IsNullOrEmpty(revisionDateString) ? (DateTime?)null : cipherDetails.RevisionDate; + + await sutProvider.Sut.SaveDetailsAsync(cipherDetails, cipherDetails.UserId.Value, lastKnownRevisionDate); + await sutProvider.GetDependency().Received(1).ReplaceAsync(cipherDetails); + } + + [Theory] + [InlineUserCipherAutoData("")] + [InlineUserCipherAutoData("Correct Time")] + public async Task ShareAsync_CorrectRevisionDate_Passes(string revisionDateString, + SutProvider sutProvider, Cipher cipher, Organization organization, List collectionIds) + { + var lastKnownRevisionDate = string.IsNullOrEmpty(revisionDateString) ? (DateTime?)null : cipher.RevisionDate; + var cipherRepository = sutProvider.GetDependency(); + cipherRepository.ReplaceAsync(cipher, collectionIds).Returns(true); + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + + await sutProvider.Sut.ShareAsync(cipher, cipher, organization.Id, collectionIds, cipher.UserId.Value, + lastKnownRevisionDate); + await cipherRepository.Received(1).ReplaceAsync(cipher, collectionIds); + } + + [Theory] + [InlineKnownUserCipherAutoData(userId: "99ab4f6c-44f8-4ff5-be7a-75c37c33c69e", "")] + [InlineKnownUserCipherAutoData(userId: "99ab4f6c-44f8-4ff5-be7a-75c37c33c69e", "CorrectTime")] + public async Task ShareManyAsync_CorrectRevisionDate_Passes(string revisionDateString, + SutProvider sutProvider, IEnumerable ciphers, Organization organization, List collectionIds) + { + var cipherInfos = ciphers.Select(c => (c, + string.IsNullOrEmpty(revisionDateString) ? null : (DateTime?)c.RevisionDate)); + var sharingUserId = ciphers.First().UserId.Value; + + await sutProvider.Sut.ShareManyAsync(cipherInfos, organization.Id, collectionIds, sharingUserId); + await sutProvider.GetDependency().Received(1).UpdateCiphersAsync(sharingUserId, + Arg.Is>(arg => arg.Except(ciphers).IsNullOrEmpty())); } } }