using System.Text.Json; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing.Premium; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Test.AutoFixture.CipherFixtures; using Bit.Core.Utilities; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; using Bit.Core.Vault.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using Xunit; namespace Bit.Core.Test.Services; [UserCipherCustomize] [SutProviderCustomize] public class CipherServiceTests { [Theory, BitAutoData] public async Task SaveAsync_WrongRevisionDate_Throws(SutProvider sutProvider, Cipher cipher) { var lastKnownRevisionDate = cipher.RevisionDate.AddDays(-1); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.SaveAsync(cipher, cipher.UserId.Value, lastKnownRevisionDate)); Assert.Contains("out of date", exception.Message); } [Theory, BitAutoData] public async Task SaveDetailsAsync_WrongRevisionDate_Throws(SutProvider sutProvider, CipherDetails cipherDetails) { 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, BitAutoData] 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); cipher.SetAttachments(new Dictionary { [Guid.NewGuid().ToString()] = new CipherAttachment.MetaData { } }); 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, BitAutoData] public async Task ShareManyAsync_WrongRevisionDate_Throws(SutProvider sutProvider, IEnumerable ciphers, Guid organizationId, List collectionIds) { sutProvider.GetDependency().GetByIdAsync(organizationId) .Returns(new Organization { PlanType = PlanType.EnterpriseAnnually, MaxStorageGb = 100 }); 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] [BitAutoData("")] [BitAutoData("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] [BitAutoData("")] [BitAutoData("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, BitAutoData] public async Task CreateAttachmentAsync_WrongRevisionDate_Throws(SutProvider sutProvider, Cipher cipher, Guid savingUserId) { var lastKnownRevisionDate = cipher.RevisionDate.AddDays(-1); var stream = new MemoryStream(); var fileName = "test.txt"; var key = "test-key"; var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.CreateAttachmentAsync(cipher, stream, fileName, key, 100, savingUserId, false, lastKnownRevisionDate)); Assert.Contains("out of date", exception.Message); } [Theory] [BitAutoData("")] [BitAutoData("Correct Time")] public async Task CreateAttachmentAsync_CorrectRevisionDate_DoesNotThrow(string revisionDateString, SutProvider sutProvider, CipherDetails cipher, Guid savingUserId) { var lastKnownRevisionDate = string.IsNullOrEmpty(revisionDateString) ? (DateTime?)null : cipher.RevisionDate; var stream = new MemoryStream(new byte[100]); var fileName = "test.txt"; var key = "test-key"; // Setup cipher with user ownership cipher.UserId = savingUserId; cipher.OrganizationId = null; // Mock user storage and premium access var user = new User { Id = savingUserId, MaxStorageGb = 1 }; sutProvider.GetDependency() .GetByIdAsync(savingUserId) .Returns(user); sutProvider.GetDependency() .CanAccessPremium(user) .Returns(true); sutProvider.GetDependency() .UploadNewAttachmentAsync(Arg.Any(), cipher, Arg.Any()) .Returns(Task.CompletedTask); sutProvider.GetDependency() .ValidateFileAsync(cipher, Arg.Any(), Arg.Any()) .Returns((true, 100L)); sutProvider.GetDependency() .UpdateAttachmentAsync(Arg.Any()) .Returns(Task.CompletedTask); sutProvider.GetDependency() .ReplaceAsync(Arg.Any()) .Returns(Task.CompletedTask); await sutProvider.Sut.CreateAttachmentAsync(cipher, stream, fileName, key, 100, savingUserId, false, lastKnownRevisionDate); await sutProvider.GetDependency().Received(1) .UploadNewAttachmentAsync(Arg.Any(), cipher, Arg.Any()); await sutProvider.GetDependency().Received(1) .LogCipherEventAsync(cipher, EventType.Cipher_AttachmentCreated); } [Theory, BitAutoData] public async Task CreateAttachmentForDelayedUploadAsync_WrongRevisionDate_Throws(SutProvider sutProvider, Cipher cipher, Guid savingUserId) { var lastKnownRevisionDate = cipher.RevisionDate.AddDays(-1); var key = "test-key"; var fileName = "test.txt"; var fileSize = 100L; var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.CreateAttachmentForDelayedUploadAsync(cipher, key, fileName, fileSize, false, savingUserId, lastKnownRevisionDate)); Assert.Contains("out of date", exception.Message); } [Theory] [BitAutoData("")] [BitAutoData("Correct Time")] public async Task CreateAttachmentForDelayedUploadAsync_CorrectRevisionDate_DoesNotThrow(string revisionDateString, SutProvider sutProvider, CipherDetails cipher, Guid savingUserId) { var lastKnownRevisionDate = string.IsNullOrEmpty(revisionDateString) ? (DateTime?)null : cipher.RevisionDate; var key = "test-key"; var fileName = "test.txt"; var fileSize = 100L; // Setup cipher with user ownership cipher.UserId = savingUserId; cipher.OrganizationId = null; // Mock user storage and premium access var user = new User { Id = savingUserId, MaxStorageGb = 1 }; sutProvider.GetDependency() .GetByIdAsync(savingUserId) .Returns(user); sutProvider.GetDependency() .CanAccessPremium(user) .Returns(true); sutProvider.GetDependency() .GetAttachmentUploadUrlAsync(cipher, Arg.Any()) .Returns("https://example.com/upload"); sutProvider.GetDependency() .UpdateAttachmentAsync(Arg.Any()) .Returns(Task.CompletedTask); var result = await sutProvider.Sut.CreateAttachmentForDelayedUploadAsync(cipher, key, fileName, fileSize, false, savingUserId, lastKnownRevisionDate); Assert.NotNull(result.attachmentId); Assert.NotNull(result.uploadUrl); await sutProvider.GetDependency().Received(1) .LogCipherEventAsync(cipher, EventType.Cipher_AttachmentCreated); } [Theory] [BitAutoData] public async Task SaveDetailsAsync_PersonalVault_WithOrganizationDataOwnershipPolicyEnabled_Throws( SutProvider sutProvider, CipherDetails cipher, Guid savingUserId) { cipher.Id = default; cipher.UserId = savingUserId; cipher.OrganizationId = null; sutProvider.GetDependency() .GetAsync(savingUserId) .Returns(new OrganizationDataOwnershipPolicyRequirement( OrganizationDataOwnershipState.Enabled, [new PolicyDetails()])); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.SaveDetailsAsync(cipher, savingUserId, null)); Assert.Contains("restricted from saving items to your personal vault", exception.Message); } [Theory] [BitAutoData] public async Task SaveDetailsAsync_PersonalVault_WithOrganizationDataOwnershipPolicyDisabled_Succeeds( SutProvider sutProvider, CipherDetails cipher, Guid savingUserId) { cipher.Id = default; cipher.UserId = savingUserId; cipher.OrganizationId = null; sutProvider.GetDependency() .GetAsync(savingUserId) .Returns(new OrganizationDataOwnershipPolicyRequirement( OrganizationDataOwnershipState.Disabled, [])); await sutProvider.Sut.SaveDetailsAsync(cipher, savingUserId, null); await sutProvider.GetDependency() .Received(1) .CreateAsync(cipher); } [Theory] [BitAutoData("")] [BitAutoData("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); cipher.SetAttachments(new Dictionary { [Guid.NewGuid().ToString()] = new CipherAttachment.MetaData { } }); await sutProvider.Sut.ShareAsync(cipher, cipher, organization.Id, collectionIds, cipher.UserId.Value, lastKnownRevisionDate); await cipherRepository.Received(1).ReplaceAsync(cipher, collectionIds); } [Theory] [BitAutoData("Correct Time")] public async Task ShareAsync_FailReplace_Throws(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(false); sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); cipher.SetAttachments(new Dictionary { [Guid.NewGuid().ToString()] = new CipherAttachment.MetaData { } }); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.ShareAsync(cipher, cipher, organization.Id, collectionIds, cipher.UserId.Value, lastKnownRevisionDate)); Assert.Contains("Unable to save", exception.Message); } [Theory] [BitAutoData("Correct Time")] public async Task ShareAsync_HasV0Attachments_ReplaceAttachmentMetadataWithNewOneBeforeSavingCipher(string revisionDateString, SutProvider sutProvider, Cipher cipher, Organization organization, List collectionIds) { var lastKnownRevisionDate = string.IsNullOrEmpty(revisionDateString) ? (DateTime?)null : cipher.RevisionDate; var originalCipher = CoreHelpers.CloneObject(cipher); var cipherRepository = sutProvider.GetDependency(); cipherRepository.ReplaceAsync(cipher, collectionIds).Returns(true); sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); var pushNotificationService = sutProvider.GetDependency(); var v0AttachmentId = Guid.NewGuid().ToString(); var anotherAttachmentId = Guid.NewGuid().ToString(); cipher.SetAttachments(new Dictionary { [v0AttachmentId] = new CipherAttachment.MetaData { AttachmentId = v0AttachmentId, ContainerName = "attachments", FileName = "AFileNameEncrypted" }, [anotherAttachmentId] = new CipherAttachment.MetaData { AttachmentId = anotherAttachmentId, Key = "AwesomeKey", FileName = "AnotherFilename", ContainerName = "attachments", Size = 300, Validated = true } }); originalCipher.SetAttachments(new Dictionary { [v0AttachmentId] = new CipherAttachment.MetaData { AttachmentId = v0AttachmentId, ContainerName = "attachments", FileName = "AFileNameEncrypted", TempMetadata = new CipherAttachment.MetaData { AttachmentId = v0AttachmentId, ContainerName = "attachments", FileName = "AFileNameRe-EncryptedWithOrgKey", Key = "NewAttachmentKey" } }, [anotherAttachmentId] = new CipherAttachment.MetaData { AttachmentId = anotherAttachmentId, Key = "AwesomeKey", FileName = "AnotherFilename", ContainerName = "attachments", Size = 300, Validated = true } }); await sutProvider.Sut.ShareAsync(originalCipher, cipher, organization.Id, collectionIds, cipher.UserId.Value, lastKnownRevisionDate); await cipherRepository.Received().ReplaceAsync(Arg.Is(c => c.GetAttachments()[v0AttachmentId].Key == "NewAttachmentKey" && c.GetAttachments()[v0AttachmentId].FileName == "AFileNameRe-EncryptedWithOrgKey") , collectionIds); await pushNotificationService.Received(1).PushSyncCipherUpdateAsync(cipher, collectionIds); } [Theory] [BitAutoData("Correct Time")] public async Task ShareAsync_HasV0Attachments_StartSharingThoseAttachments(string revisionDateString, SutProvider sutProvider, Cipher cipher, Organization organization, List collectionIds) { var lastKnownRevisionDate = string.IsNullOrEmpty(revisionDateString) ? (DateTime?)null : cipher.RevisionDate; var originalCipher = CoreHelpers.CloneObject(cipher); var cipherRepository = sutProvider.GetDependency(); cipherRepository.ReplaceAsync(cipher, collectionIds).Returns(true); sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); var attachmentStorageService = sutProvider.GetDependency(); var v0AttachmentId = Guid.NewGuid().ToString(); var anotherAttachmentId = Guid.NewGuid().ToString(); cipher.SetAttachments(new Dictionary { [v0AttachmentId] = new CipherAttachment.MetaData { AttachmentId = v0AttachmentId, ContainerName = "attachments", FileName = "AFileNameEncrypted", TempMetadata = new CipherAttachment.MetaData { AttachmentId = v0AttachmentId, ContainerName = "attachments", FileName = "AFileNameRe-EncryptedWithOrgKey", Key = "NewAttachmentKey" } }, [anotherAttachmentId] = new CipherAttachment.MetaData { AttachmentId = anotherAttachmentId, Key = "AwesomeKey", FileName = "AnotherFilename", ContainerName = "attachments", Size = 300, Validated = true } }); originalCipher.SetAttachments(new Dictionary { [v0AttachmentId] = new CipherAttachment.MetaData { AttachmentId = v0AttachmentId, ContainerName = "attachments", FileName = "AFileNameEncrypted", TempMetadata = new CipherAttachment.MetaData { AttachmentId = v0AttachmentId, ContainerName = "attachments", FileName = "AFileNameRe-EncryptedWithOrgKey", Key = "NewAttachmentKey" } }, [anotherAttachmentId] = new CipherAttachment.MetaData { AttachmentId = anotherAttachmentId, Key = "AwesomeKey", FileName = "AnotherFilename", ContainerName = "attachments", Size = 300, Validated = true } }); await sutProvider.Sut.ShareAsync(originalCipher, cipher, organization.Id, collectionIds, cipher.UserId.Value, lastKnownRevisionDate); await attachmentStorageService.Received().StartShareAttachmentAsync(cipher.Id, organization.Id, Arg.Is(m => m.Key == "NewAttachmentKey" && m.FileName == "AFileNameRe-EncryptedWithOrgKey")); await attachmentStorageService.Received(0).StartShareAttachmentAsync(cipher.Id, organization.Id, Arg.Is(m => m.Key == "AwesomeKey" && m.FileName == "AnotherFilename")); await attachmentStorageService.Received().CleanupAsync(cipher.Id); } [Theory] [BitAutoData("Correct Time")] public async Task ShareAsync_HasV0Attachments_StartShareThrows_PerformsRollback_Rethrows(string revisionDateString, SutProvider sutProvider, Cipher cipher, Organization organization, List collectionIds) { var lastKnownRevisionDate = string.IsNullOrEmpty(revisionDateString) ? (DateTime?)null : cipher.RevisionDate; var originalCipher = CoreHelpers.CloneObject(cipher); var cipherRepository = sutProvider.GetDependency(); cipherRepository.ReplaceAsync(cipher, collectionIds).Returns(true); sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); var attachmentStorageService = sutProvider.GetDependency(); var collectionCipherRepository = sutProvider.GetDependency(); collectionCipherRepository.GetManyByUserIdCipherIdAsync(cipher.UserId.Value, cipher.Id).Returns( Task.FromResult((ICollection)new List { new CollectionCipher { CipherId = cipher.Id, CollectionId = collectionIds[0] }, new CollectionCipher { CipherId = cipher.Id, CollectionId = Guid.NewGuid() } })); var v0AttachmentId = Guid.NewGuid().ToString(); var anotherAttachmentId = Guid.NewGuid().ToString(); cipher.SetAttachments(new Dictionary { [v0AttachmentId] = new CipherAttachment.MetaData { AttachmentId = v0AttachmentId, ContainerName = "attachments", FileName = "AFileNameEncrypted", TempMetadata = new CipherAttachment.MetaData { AttachmentId = v0AttachmentId, ContainerName = "attachments", FileName = "AFileNameRe-EncryptedWithOrgKey", Key = "NewAttachmentKey" } }, [anotherAttachmentId] = new CipherAttachment.MetaData { AttachmentId = anotherAttachmentId, Key = "AwesomeKey", FileName = "AnotherFilename", ContainerName = "attachments", Size = 300, Validated = true } }); originalCipher.SetAttachments(new Dictionary { [v0AttachmentId] = new CipherAttachment.MetaData { AttachmentId = v0AttachmentId, ContainerName = "attachments", FileName = "AFileNameEncrypted", TempMetadata = new CipherAttachment.MetaData { AttachmentId = v0AttachmentId, ContainerName = "attachments", FileName = "AFileNameRe-EncryptedWithOrgKey", Key = "NewAttachmentKey" } }, [anotherAttachmentId] = new CipherAttachment.MetaData { AttachmentId = anotherAttachmentId, Key = "AwesomeKey", FileName = "AnotherFilename", ContainerName = "attachments", Size = 300, Validated = true } }); attachmentStorageService.StartShareAttachmentAsync(cipher.Id, organization.Id, Arg.Is(m => m.AttachmentId == v0AttachmentId)) .Returns(Task.FromException(new InvalidOperationException("ex from StartShareAttachmentAsync"))); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.ShareAsync(cipher, cipher, organization.Id, collectionIds, cipher.UserId.Value, lastKnownRevisionDate)); Assert.Contains("ex from StartShareAttachmentAsync", exception.Message); await collectionCipherRepository.Received().UpdateCollectionsAsync(cipher.Id, cipher.UserId.Value, Arg.Is>(ids => ids.Count == 1 && ids[0] != collectionIds[0])); await cipherRepository.Received().ReplaceAsync(Arg.Is(c => c.GetAttachments()[v0AttachmentId].Key == null && c.GetAttachments()[v0AttachmentId].FileName == "AFileNameEncrypted" && c.GetAttachments()[v0AttachmentId].TempMetadata == null) ); } [Theory] [BitAutoData("Correct Time")] public async Task ShareAsync_HasSeveralV0Attachments_StartShareThrowsOnSecondOne_PerformsRollback_Rethrows(string revisionDateString, SutProvider sutProvider, Cipher cipher, Organization organization, List collectionIds) { var lastKnownRevisionDate = string.IsNullOrEmpty(revisionDateString) ? (DateTime?)null : cipher.RevisionDate; var originalCipher = CoreHelpers.CloneObject(cipher); var cipherRepository = sutProvider.GetDependency(); cipherRepository.ReplaceAsync(cipher, collectionIds).Returns(true); var organizationRepository = sutProvider.GetDependency(); organizationRepository.GetByIdAsync(organization.Id).Returns(organization); var attachmentStorageService = sutProvider.GetDependency(); var userRepository = sutProvider.GetDependency(); var collectionCipherRepository = sutProvider.GetDependency(); collectionCipherRepository.GetManyByUserIdCipherIdAsync(cipher.UserId.Value, cipher.Id).Returns( Task.FromResult((ICollection)new List { new CollectionCipher { CipherId = cipher.Id, CollectionId = collectionIds[0] }, new CollectionCipher { CipherId = cipher.Id, CollectionId = Guid.NewGuid() } })); var v0AttachmentId1 = Guid.NewGuid().ToString(); var v0AttachmentId2 = Guid.NewGuid().ToString(); var anotherAttachmentId = Guid.NewGuid().ToString(); cipher.SetAttachments(new Dictionary { [v0AttachmentId1] = new CipherAttachment.MetaData { AttachmentId = v0AttachmentId1, ContainerName = "attachments", FileName = "AFileNameEncrypted", TempMetadata = new CipherAttachment.MetaData { AttachmentId = v0AttachmentId1, ContainerName = "attachments", FileName = "AFileNameRe-EncryptedWithOrgKey", Key = "NewAttachmentKey" } }, [v0AttachmentId2] = new CipherAttachment.MetaData { AttachmentId = v0AttachmentId2, ContainerName = "attachments", FileName = "AFileNameEncrypted2", TempMetadata = new CipherAttachment.MetaData { AttachmentId = v0AttachmentId2, ContainerName = "attachments", FileName = "AFileNameRe-EncryptedWithOrgKey2", Key = "NewAttachmentKey2" } }, [anotherAttachmentId] = new CipherAttachment.MetaData { AttachmentId = anotherAttachmentId, Key = "AwesomeKey", FileName = "AnotherFilename", ContainerName = "attachments", Size = 300, Validated = true } }); originalCipher.SetAttachments(new Dictionary { [v0AttachmentId1] = new CipherAttachment.MetaData { AttachmentId = v0AttachmentId1, ContainerName = "attachments", FileName = "AFileNameEncrypted", TempMetadata = new CipherAttachment.MetaData { AttachmentId = v0AttachmentId1, ContainerName = "attachments", FileName = "AFileNameRe-EncryptedWithOrgKey", Key = "NewAttachmentKey" } }, [v0AttachmentId2] = new CipherAttachment.MetaData { AttachmentId = v0AttachmentId2, ContainerName = "attachments", FileName = "AFileNameEncrypted2", TempMetadata = new CipherAttachment.MetaData { AttachmentId = v0AttachmentId2, ContainerName = "attachments", FileName = "AFileNameRe-EncryptedWithOrgKey2", Key = "NewAttachmentKey2" } }, [anotherAttachmentId] = new CipherAttachment.MetaData { AttachmentId = anotherAttachmentId, Key = "AwesomeKey", FileName = "AnotherFilename", ContainerName = "attachments", Size = 300, Validated = true } }); attachmentStorageService.StartShareAttachmentAsync(cipher.Id, organization.Id, Arg.Is(m => m.AttachmentId == v0AttachmentId2)) .Returns(Task.FromException(new InvalidOperationException("ex from StartShareAttachmentAsync"))); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.ShareAsync(cipher, cipher, organization.Id, collectionIds, cipher.UserId.Value, lastKnownRevisionDate)); Assert.Contains("ex from StartShareAttachmentAsync", exception.Message); await collectionCipherRepository.Received().UpdateCollectionsAsync(cipher.Id, cipher.UserId.Value, Arg.Is>(ids => ids.Count == 1 && ids[0] != collectionIds[0])); await cipherRepository.Received().ReplaceAsync(Arg.Is(c => c.GetAttachments()[v0AttachmentId1].Key == null && c.GetAttachments()[v0AttachmentId1].FileName == "AFileNameEncrypted" && c.GetAttachments()[v0AttachmentId1].TempMetadata == null) ); await cipherRepository.Received().ReplaceAsync(Arg.Is(c => c.GetAttachments()[v0AttachmentId2].Key == null && c.GetAttachments()[v0AttachmentId2].FileName == "AFileNameEncrypted2" && c.GetAttachments()[v0AttachmentId2].TempMetadata == null) ); await userRepository.UpdateStorageAsync(cipher.UserId.Value); await organizationRepository.UpdateStorageAsync(organization.Id); await attachmentStorageService.Received().RollbackShareAttachmentAsync(cipher.Id, organization.Id, Arg.Is(m => m.AttachmentId == v0AttachmentId1), Arg.Any()); await attachmentStorageService.Received().CleanupAsync(cipher.Id); } [Theory] [BitAutoData("")] [BitAutoData("Correct Time")] public async Task ShareManyAsync_CorrectRevisionDate_Passes(string revisionDateString, SutProvider sutProvider, IEnumerable ciphers, Organization organization, List collectionIds) { sutProvider.GetDependency().GetByIdAsync(organization.Id) .Returns(new Organization { PlanType = PlanType.EnterpriseAnnually, MaxStorageGb = 100 }); 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).Any())); } [Theory] [BitAutoData] public async Task RestoreAsync_UpdatesUserCipher(Guid restoringUserId, CipherDetails cipher, SutProvider sutProvider) { cipher.UserId = restoringUserId; cipher.OrganizationId = null; var initialRevisionDate = new DateTime(1970, 1, 1, 0, 0, 0); cipher.DeletedDate = initialRevisionDate; cipher.RevisionDate = initialRevisionDate; sutProvider.GetDependency() .GetUserByIdAsync(restoringUserId) .Returns(new User { Id = restoringUserId, }); await sutProvider.Sut.RestoreAsync(cipher, restoringUserId); Assert.Null(cipher.DeletedDate); Assert.NotEqual(initialRevisionDate, cipher.RevisionDate); } [Theory] [OrganizationCipherCustomize] [BitAutoData] public async Task RestoreAsync_UpdatesOrganizationCipher(Guid restoringUserId, CipherDetails cipher, User user, SutProvider sutProvider) { cipher.OrganizationId = Guid.NewGuid(); cipher.Edit = false; cipher.Manage = true; sutProvider.GetDependency() .GetUserByIdAsync(restoringUserId) .Returns(user); sutProvider.GetDependency() .GetOrganizationAbilityAsync(cipher.OrganizationId.Value) .Returns(new OrganizationAbility { Id = cipher.OrganizationId.Value, LimitItemDeletion = true }); var initialRevisionDate = new DateTime(1970, 1, 1, 0, 0, 0); cipher.DeletedDate = initialRevisionDate; cipher.RevisionDate = initialRevisionDate; await sutProvider.Sut.RestoreAsync(cipher, restoringUserId); Assert.Null(cipher.DeletedDate); Assert.NotEqual(initialRevisionDate, cipher.RevisionDate); } [Theory] [BitAutoData] public async Task RestoreAsync_WithAlreadyRestoredCipher_SkipsOperation( Guid restoringUserId, CipherDetails cipherDetails, SutProvider sutProvider) { cipherDetails.DeletedDate = null; await sutProvider.Sut.RestoreAsync(cipherDetails, restoringUserId, true); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().UpsertAsync(default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().LogCipherEventAsync(default, default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().PushSyncCipherUpdateAsync(default, default); } [Theory] [BitAutoData] public async Task RestoreAsync_WithPersonalCipherBelongingToDifferentUser_ThrowsBadRequestException( Guid restoringUserId, CipherDetails cipherDetails, SutProvider sutProvider) { cipherDetails.UserId = Guid.NewGuid(); cipherDetails.OrganizationId = null; sutProvider.GetDependency() .GetUserByIdAsync(restoringUserId) .Returns(new User { Id = restoringUserId, }); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.RestoreAsync(cipherDetails, restoringUserId)); Assert.Contains("do not have permissions", exception.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().UpsertAsync(default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().LogCipherEventAsync(default, default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().PushSyncCipherUpdateAsync(default, default); } [Theory] [OrganizationCipherCustomize] [BitAutoData] public async Task RestoreAsync_WithOrgAdminOverride_RestoresCipher( Guid restoringUserId, CipherDetails cipherDetails, SutProvider sutProvider) { cipherDetails.DeletedDate = DateTime.UtcNow; await sutProvider.Sut.RestoreAsync(cipherDetails, restoringUserId, true); Assert.Null(cipherDetails.DeletedDate); Assert.NotEqual(DateTime.UtcNow, cipherDetails.RevisionDate); await sutProvider.GetDependency().Received(1).UpsertAsync(cipherDetails); await sutProvider.GetDependency().Received(1).LogCipherEventAsync(cipherDetails, EventType.Cipher_Restored); await sutProvider.GetDependency().Received(1).PushSyncCipherUpdateAsync(cipherDetails, null); } [Theory] [OrganizationCipherCustomize] [BitAutoData] public async Task RestoreAsync_WithManagePermission_RestoresCipher( Guid restoringUserId, CipherDetails cipherDetails, User user, SutProvider sutProvider) { cipherDetails.OrganizationId = Guid.NewGuid(); cipherDetails.DeletedDate = DateTime.UtcNow; cipherDetails.Edit = false; cipherDetails.Manage = true; sutProvider.GetDependency() .GetUserByIdAsync(restoringUserId) .Returns(user); sutProvider.GetDependency() .GetOrganizationAbilityAsync(cipherDetails.OrganizationId.Value) .Returns(new OrganizationAbility { Id = cipherDetails.OrganizationId.Value, LimitItemDeletion = true }); await sutProvider.Sut.RestoreAsync(cipherDetails, restoringUserId); Assert.Null(cipherDetails.DeletedDate); Assert.NotEqual(DateTime.UtcNow, cipherDetails.RevisionDate); await sutProvider.GetDependency().Received(1).UpsertAsync(cipherDetails); await sutProvider.GetDependency().Received(1).LogCipherEventAsync(cipherDetails, EventType.Cipher_Restored); await sutProvider.GetDependency().Received(1).PushSyncCipherUpdateAsync(cipherDetails, null); } [Theory] [OrganizationCipherCustomize] [BitAutoData] public async Task RestoreAsync_WithoutManagePermission_ThrowsBadRequestException( Guid restoringUserId, CipherDetails cipherDetails, User user, SutProvider sutProvider) { cipherDetails.OrganizationId = Guid.NewGuid(); cipherDetails.DeletedDate = DateTime.UtcNow; cipherDetails.Edit = true; cipherDetails.Manage = false; sutProvider.GetDependency() .GetUserByIdAsync(restoringUserId) .Returns(user); sutProvider.GetDependency() .GetOrganizationAbilityAsync(cipherDetails.OrganizationId.Value) .Returns(new OrganizationAbility { Id = cipherDetails.OrganizationId.Value, LimitItemDeletion = true }); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.RestoreAsync(cipherDetails, restoringUserId)); Assert.Contains("do not have permissions", exception.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().UpsertAsync(default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().LogCipherEventAsync(default, default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().PushSyncCipherUpdateAsync(default, default); } [Theory] [BitAutoData] public async Task RestoreManyAsync_WithOrgAdmin_UpdatesCiphers(Guid organizationId, ICollection ciphers, SutProvider sutProvider) { var cipherIds = ciphers.Select(c => c.Id).ToArray(); var restoringUserId = ciphers.First().UserId.Value; var previousRevisionDate = DateTime.UtcNow; foreach (var cipher in ciphers) { cipher.RevisionDate = previousRevisionDate; cipher.OrganizationId = organizationId; } sutProvider.GetDependency().GetManyOrganizationDetailsByOrganizationIdAsync(organizationId).Returns(ciphers); var revisionDate = previousRevisionDate + TimeSpan.FromMinutes(1); sutProvider.GetDependency().RestoreByIdsOrganizationIdAsync(Arg.Is>(ids => ids.All(i => cipherIds.Contains(i))), organizationId).Returns(revisionDate); await sutProvider.Sut.RestoreManyAsync(cipherIds, restoringUserId, organizationId, true); foreach (var cipher in ciphers) { Assert.Null(cipher.DeletedDate); Assert.Equal(revisionDate, cipher.RevisionDate); } await sutProvider.GetDependency().Received(1).LogCipherEventsAsync(Arg.Is>>(events => events.All(e => cipherIds.Contains(e.Item1.Id)))); await sutProvider.GetDependency().Received(1).PushSyncCiphersAsync(restoringUserId); } [Theory] [BitAutoData] public async Task RestoreManyAsync_WithEmptyCipherIdsArray_DoesNothing(Guid restoringUserId, SutProvider sutProvider) { var cipherIds = Array.Empty(); await sutProvider.Sut.RestoreManyAsync(cipherIds, restoringUserId); await AssertNoActionsAsync(sutProvider); } [Theory] [BitAutoData] public async Task RestoreManyAsync_WithNullCipherIdsArray_DoesNothing(Guid restoringUserId, SutProvider sutProvider) { await sutProvider.Sut.RestoreManyAsync(null, restoringUserId); await AssertNoActionsAsync(sutProvider); } [Theory] [BitAutoData] public async Task RestoreManyAsync_WithPersonalCipherBelongingToDifferentUser_DoesNotRestoreCiphers( Guid restoringUserId, List ciphers, SutProvider sutProvider) { var cipherIds = ciphers.Select(c => c.Id).ToArray(); var differentUserId = Guid.NewGuid(); foreach (var cipher in ciphers) { cipher.UserId = differentUserId; cipher.OrganizationId = null; cipher.DeletedDate = DateTime.UtcNow; } sutProvider.GetDependency() .GetManyByUserIdAsync(restoringUserId) .Returns(new List()); var result = await sutProvider.Sut.RestoreManyAsync(cipherIds, restoringUserId); Assert.Empty(result); await sutProvider.GetDependency() .Received(1) .RestoreAsync(Arg.Is>(ids => !ids.Any()), restoringUserId); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .LogCipherEventsAsync(Arg.Any>>()); await sutProvider.GetDependency() .Received(1) .PushSyncCiphersAsync(restoringUserId); } [Theory] [OrganizationCipherCustomize] [BitAutoData] public async Task RestoreManyAsync_WithManagePermission_RestoresCiphers( Guid restoringUserId, List ciphers, User user, SutProvider sutProvider) { var organizationId = Guid.NewGuid(); var cipherIds = ciphers.Select(c => c.Id).ToArray(); var previousRevisionDate = DateTime.UtcNow; foreach (var cipher in ciphers) { cipher.OrganizationId = organizationId; cipher.Edit = false; cipher.Manage = true; cipher.DeletedDate = DateTime.UtcNow; cipher.RevisionDate = previousRevisionDate; } sutProvider.GetDependency() .GetManyByUserIdAsync(restoringUserId) .Returns(ciphers); sutProvider.GetDependency() .GetUserByIdAsync(restoringUserId) .Returns(user); sutProvider.GetDependency() .GetOrganizationAbilitiesAsync(Arg.Any>()) .Returns(new Dictionary { { organizationId, new OrganizationAbility { Id = organizationId, LimitItemDeletion = true } } }); var revisionDate = previousRevisionDate + TimeSpan.FromMinutes(1); sutProvider.GetDependency() .RestoreAsync(Arg.Any>(), restoringUserId) .Returns(revisionDate); var result = await sutProvider.Sut.RestoreManyAsync(cipherIds, restoringUserId); Assert.Equal(ciphers.Count, result.Count); foreach (var cipher in result) { Assert.Null(cipher.DeletedDate); Assert.Equal(revisionDate, cipher.RevisionDate); } await sutProvider.GetDependency() .Received(1) .RestoreAsync(Arg.Is>(ids => ids.Count() == cipherIds.Count() && ids.All(id => cipherIds.Contains(id))), restoringUserId); await sutProvider.GetDependency() .Received(1) .LogCipherEventsAsync(Arg.Any>>()); await sutProvider.GetDependency() .Received(1) .PushSyncCiphersAsync(restoringUserId); } [Theory] [OrganizationCipherCustomize] [BitAutoData] public async Task RestoreManyAsync_WithoutManagePermission_DoesNotRestoreCiphers( Guid restoringUserId, List ciphers, User user, SutProvider sutProvider) { var organizationId = Guid.NewGuid(); var cipherIds = ciphers.Select(c => c.Id).ToArray(); foreach (var cipher in ciphers) { cipher.OrganizationId = organizationId; cipher.Edit = true; cipher.Manage = false; cipher.DeletedDate = DateTime.UtcNow; } sutProvider.GetDependency() .GetManyByUserIdAsync(restoringUserId) .Returns(ciphers); sutProvider.GetDependency() .GetUserByIdAsync(restoringUserId) .Returns(user); sutProvider.GetDependency() .GetOrganizationAbilitiesAsync(Arg.Any>()) .Returns(new Dictionary { { organizationId, new OrganizationAbility { Id = organizationId, LimitItemDeletion = true } } }); var result = await sutProvider.Sut.RestoreManyAsync(cipherIds, restoringUserId); Assert.Empty(result); await sutProvider.GetDependency() .Received(1) .RestoreAsync(Arg.Is>(ids => !ids.Any()), restoringUserId); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .LogCipherEventsAsync(Arg.Any>>()); await sutProvider.GetDependency() .Received(1) .PushSyncCiphersAsync(restoringUserId); } [Theory, BitAutoData] public async Task ShareManyAsync_FreeOrgWithAttachment_Throws(SutProvider sutProvider, IEnumerable ciphers, Guid organizationId, List collectionIds) { sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(new Organization { PlanType = PlanType.Free }); ciphers.FirstOrDefault().Attachments = "{\"attachment1\":{\"Size\":\"250\",\"FileName\":\"superCoolFile\"," + "\"Key\":\"superCoolFile\",\"ContainerName\":\"testContainer\",\"Validated\":false}}"; var cipherInfos = ciphers.Select(c => (c, (DateTime?)c.RevisionDate)); var sharingUserId = ciphers.First().UserId.Value; var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.ShareManyAsync(cipherInfos, organizationId, collectionIds, sharingUserId)); Assert.Contains("This organization cannot use attachments", exception.Message); } [Theory, BitAutoData] public async Task ShareManyAsync_PaidOrgWithAttachment_Passes(SutProvider sutProvider, IEnumerable ciphers, Guid organizationId, List collectionIds) { sutProvider.GetDependency().GetByIdAsync(organizationId) .Returns(new Organization { UsePolicies = true, PlanType = PlanType.EnterpriseAnnually, MaxStorageGb = 100 }); ciphers.FirstOrDefault().Attachments = "{\"attachment1\":{\"Size\":\"250\",\"FileName\":\"superCoolFile\"," + "\"Key\":\"superCoolFile\",\"ContainerName\":\"testContainer\",\"Validated\":false}}"; var cipherInfos = ciphers.Select(c => (c, (DateTime?)c.RevisionDate)); var sharingUserId = ciphers.First().UserId.Value; await sutProvider.Sut.ShareManyAsync(cipherInfos, organizationId, collectionIds, sharingUserId); await sutProvider.GetDependency().Received(1).UpdateCiphersAsync(sharingUserId, Arg.Is>(arg => !arg.Except(ciphers).Any())); } [Theory, BitAutoData] public async Task ShareManyAsync_StorageLimitBypass_Passes(SutProvider sutProvider, IEnumerable ciphers, Guid organizationId, List collectionIds) { sutProvider.GetDependency().GetByIdAsync(organizationId) .Returns(new Organization { Id = organizationId, PlanType = PlanType.EnterpriseAnnually, UsePolicies = true, MaxStorageGb = 3, Storage = 3221225472 // 3 GB used, so 0 remaining }); ciphers.FirstOrDefault().Attachments = "{\"attachment1\":{\"Size\":\"250\",\"FileName\":\"superCoolFile\"," + "\"Key\":\"superCoolFile\",\"ContainerName\":\"testContainer\",\"Validated\":false}}"; var cipherInfos = ciphers.Select(c => (c, (DateTime?)c.RevisionDate)); var sharingUserId = ciphers.First().UserId.Value; sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.MigrateMyVaultToMyItems).Returns(true); sutProvider.GetDependency() .GetAsync(sharingUserId) .Returns(new OrganizationDataOwnershipPolicyRequirement( OrganizationDataOwnershipState.Enabled, [new PolicyDetails { OrganizationId = organizationId, PolicyType = PolicyType.OrganizationDataOwnership, OrganizationUserStatus = OrganizationUserStatusType.Confirmed, }])); await sutProvider.Sut.ShareManyAsync(cipherInfos, organizationId, collectionIds, sharingUserId); await sutProvider.GetDependency().Received(1).UpdateCiphersAsync(sharingUserId, Arg.Is>(arg => !arg.Except(ciphers).Any())); } [Theory, BitAutoData] public async Task ShareManyAsync_StorageLimit_Enforced(SutProvider sutProvider, IEnumerable ciphers, Guid organizationId, List collectionIds) { sutProvider.GetDependency().GetByIdAsync(organizationId) .Returns(new Organization { Id = organizationId, PlanType = PlanType.EnterpriseAnnually, UsePolicies = true, MaxStorageGb = 3, Storage = 3221225472 // 3 GB used, so 0 remaining }); ciphers.FirstOrDefault().Attachments = "{\"attachment1\":{\"Size\":\"250\",\"FileName\":\"superCoolFile\"," + "\"Key\":\"superCoolFile\",\"ContainerName\":\"testContainer\",\"Validated\":false}}"; var cipherInfos = ciphers.Select(c => (c, (DateTime?)c.RevisionDate)); var sharingUserId = ciphers.First().UserId.Value; sutProvider.GetDependency() .GetAsync(sharingUserId) .Returns(new OrganizationDataOwnershipPolicyRequirement(OrganizationDataOwnershipState.Disabled, [])); var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.ShareManyAsync(cipherInfos, organizationId, collectionIds, sharingUserId) ); Assert.Contains("Not enough storage available for this organization.", exception.Message); await sutProvider.GetDependency().DidNotReceive().UpdateCiphersAsync(sharingUserId, Arg.Is>(arg => !arg.Except(ciphers).Any())); } [Theory, BitAutoData] public async Task ShareManyAsync_StorageLimit_Enforced_WhenFeatureFlagDisabled(SutProvider sutProvider, IEnumerable ciphers, Guid organizationId, List collectionIds) { sutProvider.GetDependency().GetByIdAsync(organizationId) .Returns(new Organization { Id = organizationId, PlanType = PlanType.EnterpriseAnnually, UsePolicies = true, MaxStorageGb = 3, Storage = 3221225472 // 3 GB used, so 0 remaining }); ciphers.FirstOrDefault().Attachments = "{\"attachment1\":{\"Size\":\"250\",\"FileName\":\"superCoolFile\"," + "\"Key\":\"superCoolFile\",\"ContainerName\":\"testContainer\",\"Validated\":false}}"; var cipherInfos = ciphers.Select(c => (c, (DateTime?)c.RevisionDate)); var sharingUserId = ciphers.First().UserId.Value; sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.MigrateMyVaultToMyItems).Returns(false); var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.ShareManyAsync(cipherInfos, organizationId, collectionIds, sharingUserId) ); Assert.Contains("Not enough storage available for this organization.", exception.Message); await sutProvider.GetDependency().DidNotReceive().UpdateCiphersAsync(sharingUserId, Arg.Is>(arg => !arg.Except(ciphers).Any())); } [Theory, BitAutoData] public async Task ShareManyAsync_StorageLimit_Enforced_WhenUsePoliciesDisabled(SutProvider sutProvider, IEnumerable ciphers, Guid organizationId, List collectionIds) { sutProvider.GetDependency().GetByIdAsync(organizationId) .Returns(new Organization { Id = organizationId, PlanType = PlanType.EnterpriseAnnually, UsePolicies = false, MaxStorageGb = 3, Storage = 3221225472 // 3 GB used, so 0 remaining }); ciphers.FirstOrDefault().Attachments = "{\"attachment1\":{\"Size\":\"250\",\"FileName\":\"superCoolFile\"," + "\"Key\":\"superCoolFile\",\"ContainerName\":\"testContainer\",\"Validated\":false}}"; var cipherInfos = ciphers.Select(c => (c, (DateTime?)c.RevisionDate)); var sharingUserId = ciphers.First().UserId.Value; sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.MigrateMyVaultToMyItems).Returns(true); var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.ShareManyAsync(cipherInfos, organizationId, collectionIds, sharingUserId) ); Assert.Contains("Not enough storage available for this organization.", exception.Message); await sutProvider.GetDependency().DidNotReceive().UpdateCiphersAsync(sharingUserId, Arg.Is>(arg => !arg.Except(ciphers).Any())); } [Theory] [BitAutoData] public async Task DeleteAsync_WithPersonalCipherOwner_DeletesCipher( Guid deletingUserId, CipherDetails cipherDetails, SutProvider sutProvider) { cipherDetails.UserId = deletingUserId; cipherDetails.OrganizationId = null; sutProvider.GetDependency() .GetUserByIdAsync(deletingUserId) .Returns(new User { Id = deletingUserId, }); await sutProvider.Sut.DeleteAsync(cipherDetails, deletingUserId); await sutProvider.GetDependency().Received(1).DeleteAsync(cipherDetails); await sutProvider.GetDependency().Received(1).DeleteAttachmentsForCipherAsync(cipherDetails.Id); await sutProvider.GetDependency().Received(1).LogCipherEventAsync(cipherDetails, EventType.Cipher_Deleted); await sutProvider.GetDependency().Received(1).PushSyncCipherDeleteAsync(cipherDetails); } [Theory] [BitAutoData] public async Task DeleteAsync_WithPersonalCipherBelongingToDifferentUser_ThrowsBadRequestException( Guid deletingUserId, CipherDetails cipherDetails, SutProvider sutProvider) { cipherDetails.UserId = Guid.NewGuid(); cipherDetails.OrganizationId = null; sutProvider.GetDependency() .GetUserByIdAsync(deletingUserId) .Returns(new User { Id = deletingUserId, }); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.DeleteAsync(cipherDetails, deletingUserId)); Assert.Contains("do not have permissions", exception.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().DeleteAsync(default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().DeleteAttachmentsForCipherAsync(default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().LogCipherEventAsync(default, default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().PushSyncCipherDeleteAsync(default); } [Theory] [OrganizationCipherCustomize] [BitAutoData] public async Task DeleteAsync_WithOrgAdminOverride_DeletesCipher( Guid deletingUserId, CipherDetails cipherDetails, SutProvider sutProvider) { await sutProvider.Sut.DeleteAsync(cipherDetails, deletingUserId, true); await sutProvider.GetDependency().Received(1).DeleteAsync(cipherDetails); await sutProvider.GetDependency().Received(1).DeleteAttachmentsForCipherAsync(cipherDetails.Id); await sutProvider.GetDependency().Received(1).LogCipherEventAsync(cipherDetails, EventType.Cipher_Deleted); await sutProvider.GetDependency().Received(1).PushSyncCipherDeleteAsync(cipherDetails); } [Theory] [OrganizationCipherCustomize] [BitAutoData] public async Task DeleteAsync_WithManagePermission_DeletesCipher( Guid deletingUserId, CipherDetails cipherDetails, User user, SutProvider sutProvider) { cipherDetails.OrganizationId = Guid.NewGuid(); cipherDetails.Edit = false; cipherDetails.Manage = true; sutProvider.GetDependency() .GetUserByIdAsync(deletingUserId) .Returns(user); sutProvider.GetDependency() .GetOrganizationAbilityAsync(cipherDetails.OrganizationId.Value) .Returns(new OrganizationAbility { Id = cipherDetails.OrganizationId.Value, LimitItemDeletion = true }); await sutProvider.Sut.DeleteAsync(cipherDetails, deletingUserId); await sutProvider.GetDependency().Received(1).DeleteAsync(cipherDetails); await sutProvider.GetDependency().Received(1).DeleteAttachmentsForCipherAsync(cipherDetails.Id); await sutProvider.GetDependency().Received(1).LogCipherEventAsync(cipherDetails, EventType.Cipher_Deleted); await sutProvider.GetDependency().Received(1).PushSyncCipherDeleteAsync(cipherDetails); } [Theory] [OrganizationCipherCustomize] [BitAutoData] public async Task DeleteAsync_WithoutManagePermission_ThrowsBadRequestException( Guid deletingUserId, CipherDetails cipherDetails, User user, SutProvider sutProvider) { cipherDetails.OrganizationId = Guid.NewGuid(); cipherDetails.Edit = true; cipherDetails.Manage = false; sutProvider.GetDependency() .GetUserByIdAsync(deletingUserId) .Returns(user); sutProvider.GetDependency() .GetOrganizationAbilityAsync(cipherDetails.OrganizationId.Value) .Returns(new OrganizationAbility { Id = cipherDetails.OrganizationId.Value, LimitItemDeletion = true }); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.DeleteAsync(cipherDetails, deletingUserId)); Assert.Contains("do not have permissions", exception.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().DeleteAsync(default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().DeleteAttachmentsForCipherAsync(default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().LogCipherEventAsync(default, default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().PushSyncCipherDeleteAsync(default); } [Theory] [OrganizationCipherCustomize] [BitAutoData] public async Task DeleteManyAsync_WithOrgAdminOverride_DeletesCiphers( Guid deletingUserId, List ciphers, Guid organizationId, SutProvider sutProvider) { var cipherIds = ciphers.Select(c => c.Id).ToArray(); foreach (var cipher in ciphers) { cipher.OrganizationId = organizationId; } sutProvider.GetDependency() .GetManyByOrganizationIdAsync(organizationId) .Returns(ciphers); await sutProvider.Sut.DeleteManyAsync(cipherIds, deletingUserId, organizationId, true); await sutProvider.GetDependency() .Received(1) .DeleteByIdsOrganizationIdAsync(Arg.Is>(ids => ids.Count() == cipherIds.Count() && ids.All(id => cipherIds.Contains(id))), organizationId); foreach (var cipher in ciphers) { await sutProvider.GetDependency() .Received(1) .DeleteAttachmentsForCipherAsync(cipher.Id); } await sutProvider.GetDependency() .Received(1) .LogCipherEventsAsync(Arg.Any>>()); await sutProvider.GetDependency() .Received(1) .PushSyncCiphersAsync(deletingUserId); } [Theory] [BitAutoData] public async Task DeleteManyAsync_WithPersonalCipherOwner_DeletesCiphers( Guid deletingUserId, List ciphers, SutProvider sutProvider) { var cipherIds = ciphers.Select(c => c.Id).ToArray(); foreach (var cipher in ciphers) { cipher.UserId = deletingUserId; cipher.OrganizationId = null; cipher.Edit = true; } sutProvider.GetDependency() .GetUserByIdAsync(deletingUserId) .Returns(new User { Id = deletingUserId, }); sutProvider.GetDependency() .GetManyByUserIdAsync(deletingUserId) .Returns(ciphers); await sutProvider.Sut.DeleteManyAsync(cipherIds, deletingUserId); await sutProvider.GetDependency() .Received(1) .DeleteAsync(Arg.Is>(ids => ids.Count() == cipherIds.Count() && ids.All(id => cipherIds.Contains(id))), deletingUserId); foreach (var cipher in ciphers) { await sutProvider.GetDependency() .Received(1) .DeleteAttachmentsForCipherAsync(cipher.Id); } await sutProvider.GetDependency() .Received(1) .LogCipherEventsAsync(Arg.Any>>()); await sutProvider.GetDependency() .Received(1) .PushSyncCiphersAsync(deletingUserId); } [Theory] [BitAutoData] public async Task DeleteManyAsync_WithPersonalCipherBelongingToDifferentUser_DoesNotDeleteCiphers( Guid deletingUserId, List ciphers, SutProvider sutProvider) { var cipherIds = ciphers.Select(c => c.Id).ToArray(); var differentUserId = Guid.NewGuid(); foreach (var cipher in ciphers) { cipher.UserId = differentUserId; cipher.OrganizationId = null; } sutProvider.GetDependency() .GetManyByUserIdAsync(deletingUserId) .Returns(new List()); await sutProvider.Sut.DeleteManyAsync(cipherIds, deletingUserId); await sutProvider.GetDependency() .Received(1) .DeleteAsync(Arg.Is>(ids => !ids.Any()), deletingUserId); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .LogCipherEventsAsync(Arg.Any>>()); await sutProvider.GetDependency() .Received(1) .PushSyncCiphersAsync(deletingUserId); } [Theory] [OrganizationCipherCustomize] [BitAutoData] public async Task DeleteManyAsync_WithoutManagePermission_DoesNotDeleteCiphers( Guid deletingUserId, List ciphers, User user, SutProvider sutProvider) { var organizationId = Guid.NewGuid(); var cipherIds = ciphers.Select(c => c.Id).ToArray(); foreach (var cipher in ciphers) { cipher.OrganizationId = organizationId; cipher.Edit = true; cipher.Manage = false; } sutProvider.GetDependency() .GetManyByUserIdAsync(deletingUserId) .Returns(ciphers); sutProvider.GetDependency() .GetUserByIdAsync(deletingUserId) .Returns(user); sutProvider.GetDependency() .GetOrganizationAbilitiesAsync(Arg.Any>()) .Returns(new Dictionary { { organizationId, new OrganizationAbility { Id = organizationId, LimitItemDeletion = true } } }); await sutProvider.Sut.DeleteManyAsync(cipherIds, deletingUserId, organizationId); await sutProvider.GetDependency() .Received(1) .DeleteAsync(Arg.Is>(ids => !ids.Any()), deletingUserId); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .LogCipherEventsAsync(Arg.Any>>()); await sutProvider.GetDependency() .Received(1) .PushSyncCiphersAsync(deletingUserId); } [Theory] [OrganizationCipherCustomize] [BitAutoData] public async Task DeleteManyAsync_WithManagePermission_DeletesCiphers( Guid deletingUserId, List ciphers, User user, SutProvider sutProvider) { var organizationId = Guid.NewGuid(); var cipherIds = ciphers.Select(c => c.Id).ToArray(); foreach (var cipher in ciphers) { cipher.OrganizationId = organizationId; cipher.Edit = false; cipher.Manage = true; } sutProvider.GetDependency() .GetManyByUserIdAsync(deletingUserId) .Returns(ciphers); sutProvider.GetDependency() .GetUserByIdAsync(deletingUserId) .Returns(user); sutProvider.GetDependency() .GetOrganizationAbilitiesAsync(Arg.Any>()) .Returns(new Dictionary { { organizationId, new OrganizationAbility { Id = organizationId, LimitItemDeletion = true } } }); await sutProvider.Sut.DeleteManyAsync(cipherIds, deletingUserId, organizationId); await sutProvider.GetDependency() .Received(1) .DeleteAsync(Arg.Is>(ids => ids.Count() == cipherIds.Count() && ids.All(id => cipherIds.Contains(id))), deletingUserId); await sutProvider.GetDependency() .Received(1) .LogCipherEventsAsync(Arg.Any>>()); await sutProvider.GetDependency() .Received(1) .PushSyncCiphersAsync(deletingUserId); } [Theory] [OrganizationCipherCustomize] [BitAutoData] public async Task DeleteManyAsync_WithOrgCipherNotFoundInCache_ThrowsNotFoundException( Guid deletingUserId, List ciphers, User user, SutProvider sutProvider) { var targetOrgId = Guid.NewGuid(); var orgIdNotInCache = Guid.NewGuid(); var cipherDetailsNotInCache = new CipherDetails { Id = Guid.NewGuid(), OrganizationId = orgIdNotInCache, Manage = true }; foreach (var cipher in ciphers) { cipher.OrganizationId = targetOrgId; cipher.Manage = true; } var cipherIds = ciphers.Concat([cipherDetailsNotInCache]).Select(c => c.Id).ToArray(); var allCiphers = ciphers.Concat([cipherDetailsNotInCache]).ToList(); sutProvider.GetDependency() .GetManyByUserIdAsync(deletingUserId) .Returns(allCiphers); sutProvider.GetDependency() .GetUserByIdAsync(deletingUserId) .Returns(user); sutProvider.GetDependency() .GetOrganizationAbilitiesAsync(Arg.Any>()) .Returns(new Dictionary { { targetOrgId, new OrganizationAbility { Id = targetOrgId, LimitItemDeletion = true } } }); // Assert var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteManyAsync(cipherIds, deletingUserId)); Assert.Contains("Cipher does not belong to the input organization.", exception.Message); await sutProvider.GetDependency() .Received(1) .GetOrganizationAbilitiesAsync(Arg.Is>(ids => ids.Contains(targetOrgId) && ids.Contains(orgIdNotInCache))); } [Theory] [BitAutoData] public async Task PurgeAsync_WithOrganizationId_DeletesCiphersAndAttachments( Organization org, List ciphers, SutProvider sutProvider) { foreach (var cipher in ciphers) { cipher.OrganizationId = org.Id; cipher.Attachments = JsonSerializer.Serialize( new Dictionary { { "attachment1", new CipherAttachment.MetaData() } }); } sutProvider.GetDependency() .GetByIdAsync(org.Id) .Returns(org); sutProvider.GetDependency() .GetManyByOrganizationIdAsync(org.Id) .Returns(ciphers); await sutProvider.Sut.PurgeAsync(org.Id); await sutProvider.GetDependency() .Received(1) .DeleteByOrganizationIdAsync(org.Id); foreach (var cipher in ciphers) { await sutProvider.GetDependency() .Received(1) .DeleteAttachmentsForCipherAsync(cipher.Id); } await sutProvider.GetDependency() .Received(1) .LogOrganizationEventAsync(org, EventType.Organization_PurgedVault); } [Theory] [BitAutoData] public async Task SoftDeleteAsync_WithPersonalCipherOwner_SoftDeletesCipher( Guid deletingUserId, CipherDetails cipherDetails, SutProvider sutProvider) { cipherDetails.UserId = deletingUserId; cipherDetails.OrganizationId = null; cipherDetails.DeletedDate = null; sutProvider.GetDependency() .GetUserByIdAsync(deletingUserId) .Returns(new User { Id = deletingUserId, }); await sutProvider.Sut.SoftDeleteAsync(cipherDetails, deletingUserId); Assert.NotNull(cipherDetails.DeletedDate); Assert.Equal(cipherDetails.RevisionDate, cipherDetails.DeletedDate); await sutProvider.GetDependency().Received(1).UpsertAsync(cipherDetails); await sutProvider.GetDependency().Received(1).LogCipherEventAsync(cipherDetails, EventType.Cipher_SoftDeleted); await sutProvider.GetDependency().Received(1).PushSyncCipherUpdateAsync(cipherDetails, null); } [Theory] [BitAutoData] public async Task SoftDeleteAsync_WithPersonalCipherBelongingToDifferentUser_ThrowsBadRequestException( Guid deletingUserId, CipherDetails cipherDetails, SutProvider sutProvider) { cipherDetails.UserId = Guid.NewGuid(); cipherDetails.OrganizationId = null; sutProvider.GetDependency() .GetUserByIdAsync(deletingUserId) .Returns(new User { Id = deletingUserId, }); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.SoftDeleteAsync(cipherDetails, deletingUserId)); Assert.Contains("do not have permissions", exception.Message); } [Theory] [BitAutoData] public async Task SoftDeleteAsync_WithAlreadySoftDeletedCipher_SkipsOperation( Guid deletingUserId, CipherDetails cipherDetails, SutProvider sutProvider) { // Set up as personal cipher owned by the deleting user cipherDetails.UserId = deletingUserId; cipherDetails.OrganizationId = null; cipherDetails.DeletedDate = DateTime.UtcNow.AddDays(-1); sutProvider.GetDependency() .GetUserByIdAsync(deletingUserId) .Returns(new User { Id = deletingUserId, }); await sutProvider.Sut.SoftDeleteAsync(cipherDetails, deletingUserId); await sutProvider.GetDependency().DidNotReceive().UpsertAsync(Arg.Any()); await sutProvider.GetDependency().DidNotReceive().LogCipherEventAsync(Arg.Any(), Arg.Any()); await sutProvider.GetDependency().DidNotReceive().PushSyncCipherUpdateAsync(Arg.Any(), Arg.Any>()); } [Theory] [OrganizationCipherCustomize] [BitAutoData] public async Task SoftDeleteAsync_WithOrgAdminOverride_SoftDeletesCipher( Guid deletingUserId, CipherDetails cipherDetails, SutProvider sutProvider) { cipherDetails.DeletedDate = null; await sutProvider.Sut.SoftDeleteAsync(cipherDetails, deletingUserId, true); await sutProvider.GetDependency().Received(1).UpsertAsync(cipherDetails); await sutProvider.GetDependency().Received(1).LogCipherEventAsync(cipherDetails, EventType.Cipher_SoftDeleted); await sutProvider.GetDependency().Received(1).PushSyncCipherUpdateAsync(cipherDetails, null); } [Theory] [OrganizationCipherCustomize] [BitAutoData] public async Task SoftDeleteAsync_WithManagePermission_SoftDeletesCipher( Guid deletingUserId, CipherDetails cipherDetails, User user, SutProvider sutProvider) { cipherDetails.OrganizationId = Guid.NewGuid(); cipherDetails.DeletedDate = null; cipherDetails.Edit = false; cipherDetails.Manage = true; sutProvider.GetDependency() .GetUserByIdAsync(deletingUserId) .Returns(user); sutProvider.GetDependency() .GetOrganizationAbilityAsync(cipherDetails.OrganizationId.Value) .Returns(new OrganizationAbility { Id = cipherDetails.OrganizationId.Value, LimitItemDeletion = true }); await sutProvider.Sut.SoftDeleteAsync(cipherDetails, deletingUserId); Assert.NotNull(cipherDetails.DeletedDate); Assert.Equal(cipherDetails.RevisionDate, cipherDetails.DeletedDate); await sutProvider.GetDependency().Received(1).UpsertAsync(cipherDetails); await sutProvider.GetDependency().Received(1).LogCipherEventAsync(cipherDetails, EventType.Cipher_SoftDeleted); await sutProvider.GetDependency().Received(1).PushSyncCipherUpdateAsync(cipherDetails, null); } [Theory] [OrganizationCipherCustomize] [BitAutoData] public async Task SoftDeleteAsync_WithoutManagePermission_ThrowsBadRequestException( Guid deletingUserId, CipherDetails cipherDetails, User user, SutProvider sutProvider) { cipherDetails.OrganizationId = Guid.NewGuid(); cipherDetails.DeletedDate = null; cipherDetails.Edit = true; cipherDetails.Manage = false; sutProvider.GetDependency() .GetUserByIdAsync(deletingUserId) .Returns(user); sutProvider.GetDependency() .GetOrganizationAbilityAsync(cipherDetails.OrganizationId.Value) .Returns(new OrganizationAbility { Id = cipherDetails.OrganizationId.Value, LimitItemDeletion = true }); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.SoftDeleteAsync(cipherDetails, deletingUserId)); Assert.Contains("do not have permissions", exception.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().UpsertAsync(default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().LogCipherEventAsync(default, default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().PushSyncCipherUpdateAsync(default, default); } [Theory] [OrganizationCipherCustomize] [BitAutoData] public async Task SoftDeleteManyAsync_WithOrgAdminOverride_SoftDeletesCiphers( Guid deletingUserId, List ciphers, Guid organizationId, SutProvider sutProvider) { var cipherIds = ciphers.Select(c => c.Id).ToArray(); foreach (var cipher in ciphers) { cipher.OrganizationId = organizationId; } sutProvider.GetDependency() .GetManyByOrganizationIdAsync(organizationId) .Returns(ciphers); await sutProvider.Sut.SoftDeleteManyAsync(cipherIds, deletingUserId, organizationId, true); await sutProvider.GetDependency() .Received(1) .SoftDeleteByIdsOrganizationIdAsync(Arg.Is>(ids => ids.Count() == cipherIds.Count() && ids.All(id => cipherIds.Contains(id))), organizationId); await sutProvider.GetDependency() .Received(1) .LogCipherEventsAsync(Arg.Any>>()); await sutProvider.GetDependency() .Received(1) .PushSyncCiphersAsync(deletingUserId); } [Theory] [BitAutoData] public async Task SoftDeleteManyAsync_WithPersonalCipherOwner_SoftDeletesCiphers( Guid deletingUserId, List ciphers, SutProvider sutProvider) { var cipherIds = ciphers.Select(c => c.Id).ToArray(); foreach (var cipher in ciphers) { cipher.UserId = deletingUserId; cipher.OrganizationId = null; cipher.Edit = true; cipher.DeletedDate = null; } sutProvider.GetDependency() .GetUserByIdAsync(deletingUserId) .Returns(new User { Id = deletingUserId, }); sutProvider.GetDependency() .GetManyByUserIdAsync(deletingUserId) .Returns(ciphers); await sutProvider.Sut.SoftDeleteManyAsync(cipherIds, deletingUserId, null, false); await sutProvider.GetDependency() .Received(1) .SoftDeleteAsync(Arg.Is>(ids => ids.Count() == cipherIds.Count() && ids.All(id => cipherIds.Contains(id))), deletingUserId); await sutProvider.GetDependency() .Received(1) .LogCipherEventsAsync(Arg.Any>>()); await sutProvider.GetDependency() .Received(1) .PushSyncCiphersAsync(deletingUserId); } [Theory] [BitAutoData] public async Task SoftDeleteManyAsync_WithPersonalCipherBelongingToDifferentUser_DoesNotDeleteCiphers( Guid deletingUserId, List ciphers, SutProvider sutProvider) { var cipherIds = ciphers.Select(c => c.Id).ToArray(); var differentUserId = Guid.NewGuid(); foreach (var cipher in ciphers) { cipher.UserId = differentUserId; cipher.OrganizationId = null; } sutProvider.GetDependency() .GetManyByUserIdAsync(deletingUserId) .Returns(new List()); await sutProvider.Sut.SoftDeleteManyAsync(cipherIds, deletingUserId, null, false); await sutProvider.GetDependency() .Received(1) .SoftDeleteAsync(Arg.Is>(ids => !ids.Any()), deletingUserId); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .LogCipherEventsAsync(Arg.Any>>()); await sutProvider.GetDependency() .Received(1) .PushSyncCiphersAsync(deletingUserId); } [Theory] [OrganizationCipherCustomize] [BitAutoData] public async Task SoftDeleteManyAsync_WithoutManagePermission_DoesNotDeleteCiphers( Guid deletingUserId, List ciphers, User user, SutProvider sutProvider) { var organizationId = Guid.NewGuid(); var cipherIds = ciphers.Select(c => c.Id).ToArray(); foreach (var cipher in ciphers) { cipher.OrganizationId = organizationId; cipher.Edit = true; cipher.Manage = false; } sutProvider.GetDependency() .GetManyByUserIdAsync(deletingUserId) .Returns(ciphers); sutProvider.GetDependency() .GetUserByIdAsync(deletingUserId) .Returns(user); sutProvider.GetDependency() .GetOrganizationAbilitiesAsync(Arg.Any>()) .Returns(new Dictionary { { organizationId, new OrganizationAbility { Id = organizationId, LimitItemDeletion = true } } }); await sutProvider.Sut.SoftDeleteManyAsync(cipherIds, deletingUserId, organizationId, false); await sutProvider.GetDependency() .Received(1) .SoftDeleteAsync(Arg.Is>(ids => !ids.Any()), deletingUserId); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .LogCipherEventsAsync(Arg.Any>>()); await sutProvider.GetDependency() .Received(1) .PushSyncCiphersAsync(deletingUserId); } [Theory] [OrganizationCipherCustomize] [BitAutoData] public async Task SoftDeleteManyAsync_WithManagePermission_SoftDeletesCiphers( Guid deletingUserId, List ciphers, User user, SutProvider sutProvider) { var organizationId = Guid.NewGuid(); var cipherIds = ciphers.Select(c => c.Id).ToArray(); foreach (var cipher in ciphers) { cipher.OrganizationId = organizationId; cipher.Edit = false; cipher.Manage = true; cipher.DeletedDate = null; } sutProvider.GetDependency() .GetManyByUserIdAsync(deletingUserId) .Returns(ciphers); sutProvider.GetDependency() .GetUserByIdAsync(deletingUserId) .Returns(user); sutProvider.GetDependency() .GetOrganizationAbilitiesAsync(Arg.Any>()) .Returns(new Dictionary { { organizationId, new OrganizationAbility { Id = organizationId, LimitItemDeletion = true } } }); await sutProvider.Sut.SoftDeleteManyAsync(cipherIds, deletingUserId, organizationId, false); await sutProvider.GetDependency() .Received(1) .SoftDeleteAsync(Arg.Is>(ids => ids.Count() == cipherIds.Count() && ids.All(id => cipherIds.Contains(id))), deletingUserId); await sutProvider.GetDependency() .Received(1) .LogCipherEventsAsync(Arg.Any>>()); await sutProvider.GetDependency() .Received(1) .PushSyncCiphersAsync(deletingUserId); } [Theory] [BitAutoData] public async Task SoftDeleteAsync_CallsMarkAsCompleteByCipherIds( Guid deletingUserId, CipherDetails cipherDetails, SutProvider sutProvider) { cipherDetails.UserId = deletingUserId; cipherDetails.OrganizationId = null; cipherDetails.DeletedDate = null; sutProvider.GetDependency() .GetUserByIdAsync(deletingUserId) .Returns(new User { Id = deletingUserId, }); await sutProvider.Sut.SoftDeleteAsync(cipherDetails, deletingUserId); await sutProvider.GetDependency() .Received(1) .MarkAsCompleteByCipherIds(Arg.Is>(ids => ids.Count() == 1 && ids.First() == cipherDetails.Id)); } [Theory] [BitAutoData] public async Task SoftDeleteManyAsync_CallsMarkAsCompleteByCipherIds( Guid deletingUserId, List ciphers, SutProvider sutProvider) { var cipherIds = ciphers.Select(c => c.Id).ToArray(); foreach (var cipher in ciphers) { cipher.UserId = deletingUserId; cipher.OrganizationId = null; cipher.Edit = true; cipher.DeletedDate = null; } sutProvider.GetDependency() .GetUserByIdAsync(deletingUserId) .Returns(new User { Id = deletingUserId, }); sutProvider.GetDependency() .GetManyByUserIdAsync(deletingUserId) .Returns(ciphers); await sutProvider.Sut.SoftDeleteManyAsync(cipherIds, deletingUserId, null, false); await sutProvider.GetDependency() .Received(1) .MarkAsCompleteByCipherIds(Arg.Is>(ids => ids.Count() == cipherIds.Length && ids.All(id => cipherIds.Contains(id)))); } [Theory, BitAutoData] public async Task CreateAttachmentAsync_UserWithOrgGrantedPremium_UsesStorageFromPricingClient( SutProvider sutProvider, CipherDetails cipher, Guid savingUserId) { var stream = new MemoryStream(new byte[100]); var fileName = "test.txt"; var key = "test-key"; // Setup cipher with user ownership cipher.UserId = savingUserId; cipher.OrganizationId = null; // Setup user WITHOUT personal premium (Premium = false), but with org-granted premium access var user = new User { Id = savingUserId, Premium = false, // User does not have personal premium MaxStorageGb = null, // No personal storage allocation Storage = 0 // No storage used yet }; sutProvider.GetDependency() .GetByIdAsync(savingUserId) .Returns(user); // User has premium access through their organization sutProvider.GetDependency() .CanAccessPremium(user) .Returns(true); // Mock GlobalSettings to indicate cloud (not self-hosted) sutProvider.GetDependency().SelfHosted = false; // Mock the PricingClient to return a premium plan with 1 GB of storage var premiumPlan = new Plan { Name = "Premium", Available = true, Seat = new Purchasable { StripePriceId = "price_123", Price = 10, Provided = 1 }, Storage = new Purchasable { StripePriceId = "price_456", Price = 4, Provided = 1 } }; sutProvider.GetDependency() .GetAvailablePremiumPlan() .Returns(premiumPlan); sutProvider.GetDependency() .UploadNewAttachmentAsync(Arg.Any(), cipher, Arg.Any()) .Returns(Task.CompletedTask); sutProvider.GetDependency() .ValidateFileAsync(cipher, Arg.Any(), Arg.Any()) .Returns((true, 100L)); sutProvider.GetDependency() .UpdateAttachmentAsync(Arg.Any()) .Returns(Task.CompletedTask); sutProvider.GetDependency() .ReplaceAsync(Arg.Any()) .Returns(Task.CompletedTask); // Act await sutProvider.Sut.CreateAttachmentAsync(cipher, stream, fileName, key, 100, savingUserId, false, cipher.RevisionDate); // Assert - PricingClient was called to get the premium plan storage await sutProvider.GetDependency().Received(1).GetAvailablePremiumPlan(); // Assert - Attachment was uploaded successfully await sutProvider.GetDependency().Received(1) .UploadNewAttachmentAsync(Arg.Any(), cipher, Arg.Any()); } [Theory, BitAutoData] public async Task CreateAttachmentAsync_UserWithOrgGrantedPremium_ExceedsStorage_ThrowsBadRequest( SutProvider sutProvider, CipherDetails cipher, Guid savingUserId) { var stream = new MemoryStream(new byte[100]); var fileName = "test.txt"; var key = "test-key"; // Setup cipher with user ownership cipher.UserId = savingUserId; cipher.OrganizationId = null; // Setup user WITHOUT personal premium, with org-granted access, but storage is full var user = new User { Id = savingUserId, Premium = false, MaxStorageGb = null, Storage = 1073741824 // 1 GB already used (equals the provided storage) }; sutProvider.GetDependency() .GetByIdAsync(savingUserId) .Returns(user); sutProvider.GetDependency() .CanAccessPremium(user) .Returns(true); sutProvider.GetDependency().SelfHosted = false; // Premium plan provides 1 GB of storage var premiumPlan = new Plan { Name = "Premium", Available = true, Seat = new Purchasable { StripePriceId = "price_123", Price = 10, Provided = 1 }, Storage = new Purchasable { StripePriceId = "price_456", Price = 4, Provided = 1 } }; sutProvider.GetDependency() .GetAvailablePremiumPlan() .Returns(premiumPlan); // Act & Assert - Should throw because storage is full var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.CreateAttachmentAsync(cipher, stream, fileName, key, 100, savingUserId, false, cipher.RevisionDate)); Assert.Contains("Not enough storage available", exception.Message); } [Theory, BitAutoData] public async Task CreateAttachmentAsync_UserWithOrgGrantedPremium_SelfHosted_UsesConstantStorage( SutProvider sutProvider, CipherDetails cipher, Guid savingUserId) { var stream = new MemoryStream(new byte[100]); var fileName = "test.txt"; var key = "test-key"; // Setup cipher with user ownership cipher.UserId = savingUserId; cipher.OrganizationId = null; // Setup user WITHOUT personal premium, but with org-granted premium access var user = new User { Id = savingUserId, Premium = false, MaxStorageGb = null, Storage = 0 }; sutProvider.GetDependency() .GetByIdAsync(savingUserId) .Returns(user); sutProvider.GetDependency() .CanAccessPremium(user) .Returns(true); // Mock GlobalSettings to indicate self-hosted sutProvider.GetDependency().SelfHosted = true; sutProvider.GetDependency() .UploadNewAttachmentAsync(Arg.Any(), cipher, Arg.Any()) .Returns(Task.CompletedTask); sutProvider.GetDependency() .ValidateFileAsync(cipher, Arg.Any(), Arg.Any()) .Returns((true, 100L)); sutProvider.GetDependency() .UpdateAttachmentAsync(Arg.Any()) .Returns(Task.CompletedTask); sutProvider.GetDependency() .ReplaceAsync(Arg.Any()) .Returns(Task.CompletedTask); // Act await sutProvider.Sut.CreateAttachmentAsync(cipher, stream, fileName, key, 100, savingUserId, false, cipher.RevisionDate); // Assert - PricingClient should NOT be called for self-hosted await sutProvider.GetDependency().DidNotReceive().GetAvailablePremiumPlan(); // Assert - Attachment was uploaded successfully await sutProvider.GetDependency().Received(1) .UploadNewAttachmentAsync(Arg.Any(), cipher, Arg.Any()); } private async Task AssertNoActionsAsync(SutProvider sutProvider) { await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetManyOrganizationDetailsByOrganizationIdAsync(default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().RestoreByIdsOrganizationIdAsync(default, default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().RestoreByIdsOrganizationIdAsync(default, default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetManyByUserIdAsync(default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().RestoreAsync(default, default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().LogCipherEventsAsync(default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().PushSyncCiphersAsync(default); } [Theory, BitAutoData] public async Task UploadFileForExistingAttachmentAsync_ReadOnlyUser_ThrowsBadRequest( SutProvider sutProvider, Cipher cipher, Guid savingUserId) { cipher.OrganizationId = Guid.NewGuid(); cipher.UserId = null; var attachment = new CipherAttachment.MetaData { Size = 100, FileName = "test.txt" }; sutProvider.GetDependency() .GetCanEditByIdAsync(savingUserId, cipher.Id) .Returns(false); using var stream = new MemoryStream(new byte[100]); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.UploadFileForExistingAttachmentAsync(stream, cipher, attachment, savingUserId, false)); Assert.Equal("You do not have permissions to edit this.", exception.Message); } [Theory, BitAutoData] public async Task ValidateCipherEditForAttachmentAsync_ReadOnlyUser_ThrowsBadRequest( SutProvider sutProvider, Cipher cipher, Guid savingUserId) { cipher.OrganizationId = Guid.NewGuid(); sutProvider.GetDependency() .GetCanEditByIdAsync(savingUserId, cipher.Id) .Returns(false); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.ValidateCipherEditForAttachmentAsync(cipher, savingUserId, false, 100)); Assert.Equal("You do not have permissions to edit this.", exception.Message); } [Theory, BitAutoData] public async Task ValidateCipherEditForAttachmentAsync_OrgAdmin_BypassesEditCheck( SutProvider sutProvider, Cipher cipher, Guid savingUserId) { cipher.OrganizationId = Guid.NewGuid(); cipher.UserId = null; sutProvider.GetDependency() .GetCanEditByIdAsync(savingUserId, cipher.Id) .Returns(false); var organization = new Organization { Id = cipher.OrganizationId.Value, MaxStorageGb = 100 }; sutProvider.GetDependency() .GetByIdAsync(cipher.OrganizationId.Value) .Returns(organization); await sutProvider.Sut.ValidateCipherEditForAttachmentAsync(cipher, savingUserId, true, 100); await sutProvider.GetDependency().DidNotReceive() .GetCanEditByIdAsync(savingUserId, cipher.Id); } [Theory, BitAutoData] public async Task ValidateCipherEditForAttachmentAsync_ZeroRequestLength_ThrowsBadRequest( SutProvider sutProvider, Cipher cipher, Guid savingUserId) { cipher.UserId = savingUserId; var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.ValidateCipherEditForAttachmentAsync(cipher, savingUserId, true, 0)); Assert.Equal("No data to attach.", exception.Message); } [Theory, BitAutoData] public async Task ValidateCipherEditForAttachmentAsync_UserWithEditPermission_Succeeds( SutProvider sutProvider, Cipher cipher, Guid savingUserId, User user) { cipher.UserId = savingUserId; cipher.OrganizationId = null; user.Id = savingUserId; user.Premium = true; user.MaxStorageGb = 1; user.Storage = 0; sutProvider.GetDependency() .GetByIdAsync(savingUserId) .Returns(user); sutProvider.GetDependency() .CanAccessPremium(user) .Returns(true); await sutProvider.Sut.ValidateCipherEditForAttachmentAsync(cipher, savingUserId, false, 100); } [Theory, BitAutoData] public async Task GetAttachmentDownloadDataAsync_NullCipher_ThrowsNotFoundException( string attachmentId, SutProvider sutProvider) { await Assert.ThrowsAsync( () => sutProvider.Sut.GetAttachmentDownloadDataAsync(null, attachmentId)); } [Theory, BitAutoData] public async Task GetAttachmentDownloadDataAsync_AttachmentNotFound_ThrowsNotFoundException( SutProvider sutProvider) { var cipher = new Cipher { Id = Guid.NewGuid(), Attachments = null }; await Assert.ThrowsAsync( () => sutProvider.Sut.GetAttachmentDownloadDataAsync(cipher, "nonexistent")); } [Theory, BitAutoData] public async Task GetAttachmentDownloadDataAsync_ReturnsUrlFromStorageService( SutProvider sutProvider) { var cipherId = Guid.NewGuid(); var attachmentId = Guid.NewGuid().ToString(); var expectedUrl = "https://example.com/download?token=abc"; var metaData = new CipherAttachment.MetaData { AttachmentId = attachmentId, FileName = "test.txt", Size = 100, }; var cipher = new Cipher { Id = cipherId, Attachments = System.Text.Json.JsonSerializer.Serialize( new Dictionary { { attachmentId, metaData } }), }; sutProvider.GetDependency() .GetAttachmentDownloadUrlAsync(cipher, Arg.Any()) .Returns(expectedUrl); var result = await sutProvider.Sut.GetAttachmentDownloadDataAsync(cipher, attachmentId); Assert.Equal(expectedUrl, result.Url); Assert.Equal(attachmentId, result.Id); } [Theory, BitAutoData] public async Task DeleteAttachmentsForOrganizationAsync_OnlyDeletesAttachmentsForCiphersWithAttachments( SutProvider sutProvider, Guid organizationId, List ciphersWithAttachments, List ciphersWithoutAttachments) { foreach (var cipher in ciphersWithAttachments) { cipher.Attachments = JsonSerializer.Serialize( new Dictionary { { "attachment1", new CipherAttachment.MetaData() } }); } foreach (var cipher in ciphersWithoutAttachments) { cipher.Attachments = null; } sutProvider.GetDependency() .GetManyByOrganizationIdAsync(organizationId) .Returns(ciphersWithAttachments.Concat(ciphersWithoutAttachments).ToList()); await sutProvider.Sut.DeleteAttachmentsForOrganizationAsync(organizationId); foreach (var cipher in ciphersWithAttachments) { await sutProvider.GetDependency() .Received(1) .DeleteAttachmentsForCipherAsync(cipher.Id); } foreach (var cipher in ciphersWithoutAttachments) { await sutProvider.GetDependency() .DidNotReceive() .DeleteAttachmentsForCipherAsync(cipher.Id); } } }