From d020c49c0e3957e4bf08a38be32f306e4225fdda Mon Sep 17 00:00:00 2001 From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Date: Tue, 27 Jun 2023 13:12:34 -0500 Subject: [PATCH] [SM-788] Extract authorization from secret delete command (#3003) * Extract authorization from secret delete command --- .../Secrets/SecretAuthorizationHandler.cs | 21 +++ .../Commands/Secrets/DeleteSecretCommand.cs | 68 +-------- .../SecretAuthorizationHandlerTests.cs | 79 +++++++++++ .../Secrets/DeleteSecretCommandTests.cs | 77 +---------- .../Controllers/SecretsController.cs | 38 +++++- .../SecretOperationRequirement.cs | 1 + .../Interfaces/IDeleteSecretCommand.cs | 2 +- .../Controllers/SecretsControllerTests.cs | 68 ++++++--- .../Controllers/SecretsControllerTests.cs | 129 +++++++++++++----- 9 files changed, 287 insertions(+), 196 deletions(-) diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/AuthorizationHandlers/Secrets/SecretAuthorizationHandler.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/AuthorizationHandlers/Secrets/SecretAuthorizationHandler.cs index 7ddc8b886..b02271c7a 100644 --- a/bitwarden_license/src/Commercial.Core/SecretsManager/AuthorizationHandlers/Secrets/SecretAuthorizationHandler.cs +++ b/bitwarden_license/src/Commercial.Core/SecretsManager/AuthorizationHandlers/Secrets/SecretAuthorizationHandler.cs @@ -41,6 +41,9 @@ public class SecretAuthorizationHandler : AuthorizationHandler>> DeleteSecrets(List ids, Guid userId) + public async Task DeleteSecrets(IEnumerable secrets) { - var secrets = (await _secretRepository.GetManyByIds(ids)).ToList(); - - if (secrets.Any() != true) - { - throw new NotFoundException(); - } - - // Ensure all secrets belongs to the same organization - var organizationId = secrets.First().OrganizationId; - if (secrets.Any(secret => secret.OrganizationId != organizationId)) - { - throw new BadRequestException(); - } - - if (!_currentContext.AccessSecretsManager(organizationId)) - { - throw new NotFoundException(); - } - - var orgAdmin = await _currentContext.OrganizationAdmin(organizationId); - var accessClient = AccessClientHelper.ToAccessClient(_currentContext.ClientType, orgAdmin); - - var results = new List>(); - var deleteIds = new List(); - - foreach (var secret in secrets) - { - var hasAccess = orgAdmin; - - if (secret.Projects != null && secret.Projects?.Count > 0) - { - var projectId = secret.Projects.First().Id; - hasAccess = (await _projectRepository.AccessToProjectAsync(projectId, userId, accessClient)).Write; - } - - if (!hasAccess || accessClient == AccessClientType.ServiceAccount) - { - results.Add(new Tuple(secret, "access denied")); - } - else - { - deleteIds.Add(secret.Id); - results.Add(new Tuple(secret, "")); - } - } - - - - if (deleteIds.Count > 0) - { - await _secretRepository.SoftDeleteManyByIdAsync(deleteIds); - } - - return results; + await _secretRepository.SoftDeleteManyByIdAsync(secrets.Select(s => s.Id)); } } diff --git a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/AuthorizationHandlers/Secrets/SecretAuthorizationHandlerTests.cs b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/AuthorizationHandlers/Secrets/SecretAuthorizationHandlerTests.cs index 24b9d481e..3e18a8349 100644 --- a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/AuthorizationHandlers/Secrets/SecretAuthorizationHandlerTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/AuthorizationHandlers/Secrets/SecretAuthorizationHandlerTests.cs @@ -374,4 +374,83 @@ public class SecretAuthorizationHandlerTests Assert.True(authzContext.HasSucceeded); } + + [Theory] + [BitAutoData] + public async Task CanDeleteSecret_AccessToSecretsManagerFalse_DoesNotSucceed( + SutProvider sutProvider, Secret secret, + ClaimsPrincipal claimsPrincipal) + { + var requirement = SecretOperations.Delete; + sutProvider.GetDependency().AccessSecretsManager(secret.OrganizationId) + .Returns(false); + var authzContext = new AuthorizationHandlerContext(new List { requirement }, + claimsPrincipal, secret); + + await sutProvider.Sut.HandleAsync(authzContext); + + Assert.False(authzContext.HasSucceeded); + } + + [Theory] + [BitAutoData] + public async Task CanDeleteSecret_NullResource_DoesNotSucceed( + SutProvider sutProvider, Secret secret, + ClaimsPrincipal claimsPrincipal, + Guid userId) + { + var requirement = SecretOperations.Delete; + SetupPermission(sutProvider, PermissionType.RunAsAdmin, secret.OrganizationId, userId); + var authzContext = new AuthorizationHandlerContext(new List { requirement }, + claimsPrincipal, null); + + await sutProvider.Sut.HandleAsync(authzContext); + + Assert.False(authzContext.HasSucceeded); + } + + [Theory] + [BitAutoData] + public async Task CanDeleteSecret_ServiceAccountClient_DoesNotSucceed( + SutProvider sutProvider, Secret secret, Guid userId, + ClaimsPrincipal claimsPrincipal) + { + var requirement = SecretOperations.Delete; + SetupPermission(sutProvider, PermissionType.RunAsUserWithPermission, secret.OrganizationId, userId, + AccessClientType.ServiceAccount); + sutProvider.GetDependency() + .AccessToSecretAsync(secret.Id, userId, Arg.Any()) + .Returns((true, true)); + var authzContext = new AuthorizationHandlerContext(new List { requirement }, + claimsPrincipal, secret); + + await sutProvider.Sut.HandleAsync(authzContext); + + Assert.False(authzContext.HasSucceeded); + } + + [Theory] + [BitAutoData(PermissionType.RunAsAdmin, true, true, true)] + [BitAutoData(PermissionType.RunAsUserWithPermission, false, false, false)] + [BitAutoData(PermissionType.RunAsUserWithPermission, false, true, true)] + [BitAutoData(PermissionType.RunAsUserWithPermission, true, false, false)] + [BitAutoData(PermissionType.RunAsUserWithPermission, true, true, true)] + public async Task CanDeleteProject_AccessCheck(PermissionType permissionType, bool read, bool write, + bool expected, + SutProvider sutProvider, Secret secret, + ClaimsPrincipal claimsPrincipal, + Guid userId) + { + var requirement = SecretOperations.Delete; + SetupPermission(sutProvider, permissionType, secret.OrganizationId, userId); + sutProvider.GetDependency() + .AccessToSecretAsync(secret.Id, userId, Arg.Any()) + .Returns((read, write)); + var authzContext = new AuthorizationHandlerContext(new List { requirement }, + claimsPrincipal, secret); + + await sutProvider.Sut.HandleAsync(authzContext); + + Assert.Equal(expected, authzContext.HasSucceeded); + } } diff --git a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Commands/Secrets/DeleteSecretCommandTests.cs b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Commands/Secrets/DeleteSecretCommandTests.cs index c3183e906..bdd7b1a6f 100644 --- a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Commands/Secrets/DeleteSecretCommandTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Commands/Secrets/DeleteSecretCommandTests.cs @@ -1,8 +1,4 @@ using Bit.Commercial.Core.SecretsManager.Commands.Secrets; -using Bit.Commercial.Core.Test.SecretsManager.Enums; -using Bit.Core.Context; -using Bit.Core.Enums; -using Bit.Core.Exceptions; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Repositories; using Bit.Core.Test.SecretsManager.AutoFixture.ProjectsFixture; @@ -20,75 +16,12 @@ public class DeleteSecretCommandTests { [Theory] [BitAutoData] - public async Task DeleteSecrets_Throws_NotFoundException(List data, - SutProvider sutProvider) + public async Task DeleteSecrets_Success(SutProvider sutProvider, List data) { - sutProvider.GetDependency().GetManyByIds(data).Returns(new List()); - - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteSecrets(data, default)); - - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().SoftDeleteManyByIdAsync(default); - } - - [Theory] - [BitAutoData] - public async Task DeleteSecrets_OneIdNotFound_Throws_NotFoundException(List data, - SutProvider sutProvider) - { - var secret = new Secret() - { - Id = Guid.NewGuid() - }; - sutProvider.GetDependency().GetManyByIds(data).Returns(new List() { secret }); - - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteSecrets(data, default)); - - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().SoftDeleteManyByIdAsync(default); - } - - [Theory] - [BitAutoData(PermissionType.RunAsAdmin)] - [BitAutoData(PermissionType.RunAsUserWithPermission)] - public async Task DeleteSecrets_Success(PermissionType permissionType, List data, - SutProvider sutProvider, Guid userId, Guid organizationId, Project mockProject) - { - List projects = null; - - if (permissionType == PermissionType.RunAsAdmin) - { - sutProvider.GetDependency().OrganizationAdmin(organizationId).Returns(true); - } - else - { - sutProvider.GetDependency().OrganizationAdmin(organizationId).Returns(false); - sutProvider.GetDependency().AccessToProjectAsync(mockProject.Id, userId, AccessClientType.User) - .Returns((true, true)); - projects = new List() { mockProject }; - } - - - var secrets = new List(); - foreach (Guid id in data) - { - var secret = new Secret() - { - Id = id, - OrganizationId = organizationId, - Projects = projects - }; - secrets.Add(secret); - } - - sutProvider.GetDependency().GetManyByIds(data).Returns(secrets); - sutProvider.GetDependency().AccessSecretsManager(default).ReturnsForAnyArgs(true); - - var results = await sutProvider.Sut.DeleteSecrets(data, userId); - await sutProvider.GetDependency().Received(1).SoftDeleteManyByIdAsync(Arg.Is(AssertHelper.AssertPropertyEqual(data))); - - foreach (var result in results) - { - Assert.Equal("", result.Item2); - } + await sutProvider.Sut.DeleteSecrets(data); + await sutProvider.GetDependency() + .Received(1) + .SoftDeleteManyByIdAsync(Arg.Is(AssertHelper.AssertPropertyEqual(data.Select(d => d.Id)))); } } diff --git a/src/Api/SecretsManager/Controllers/SecretsController.cs b/src/Api/SecretsManager/Controllers/SecretsController.cs index dcf62f322..99f641a17 100644 --- a/src/Api/SecretsManager/Controllers/SecretsController.cs +++ b/src/Api/SecretsManager/Controllers/SecretsController.cs @@ -8,6 +8,7 @@ using Bit.Core.Identity; using Bit.Core.Repositories; using Bit.Core.SecretsManager.AuthorizationRequirements; using Bit.Core.SecretsManager.Commands.Secrets.Interfaces; +using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Repositories; using Bit.Core.Services; using Bit.Core.Tools.Enums; @@ -170,9 +171,40 @@ public class SecretsController : Controller [HttpPost("secrets/delete")] public async Task> BulkDeleteAsync([FromBody] List ids) { - var userId = _userService.GetProperUserId(User).Value; - var results = await _deleteSecretCommand.DeleteSecrets(ids, userId); - var responses = results.Select(r => new BulkDeleteResponseModel(r.Item1.Id, r.Item2)); + var secrets = (await _secretRepository.GetManyByIds(ids)).ToList(); + if (!secrets.Any() || secrets.Count != ids.Count) + { + throw new NotFoundException(); + } + + // Ensure all secrets belong to the same organization. + var organizationId = secrets.First().OrganizationId; + if (secrets.Any(secret => secret.OrganizationId != organizationId) || + !_currentContext.AccessSecretsManager(organizationId)) + { + throw new NotFoundException(); + } + + var secretsToDelete = new List(); + var results = new List<(Secret Secret, string Error)>(); + + foreach (var secret in secrets) + { + var authorizationResult = + await _authorizationService.AuthorizeAsync(User, secret, SecretOperations.Delete); + if (authorizationResult.Succeeded) + { + secretsToDelete.Add(secret); + results.Add((secret, "")); + } + else + { + results.Add((secret, "access denied")); + } + } + + await _deleteSecretCommand.DeleteSecrets(secretsToDelete); + var responses = results.Select(r => new BulkDeleteResponseModel(r.Secret.Id, r.Error)); return new ListResponseModel(responses); } } diff --git a/src/Core/SecretsManager/AuthorizationRequirements/SecretOperationRequirement.cs b/src/Core/SecretsManager/AuthorizationRequirements/SecretOperationRequirement.cs index 8b1e8a613..c6956ed30 100644 --- a/src/Core/SecretsManager/AuthorizationRequirements/SecretOperationRequirement.cs +++ b/src/Core/SecretsManager/AuthorizationRequirements/SecretOperationRequirement.cs @@ -10,4 +10,5 @@ public static class SecretOperations { public static readonly SecretOperationRequirement Create = new() { Name = nameof(Create) }; public static readonly SecretOperationRequirement Update = new() { Name = nameof(Update) }; + public static readonly SecretOperationRequirement Delete = new() { Name = nameof(Delete) }; } diff --git a/src/Core/SecretsManager/Commands/Secrets/Interfaces/IDeleteSecretCommand.cs b/src/Core/SecretsManager/Commands/Secrets/Interfaces/IDeleteSecretCommand.cs index 2517c70df..300a81035 100644 --- a/src/Core/SecretsManager/Commands/Secrets/Interfaces/IDeleteSecretCommand.cs +++ b/src/Core/SecretsManager/Commands/Secrets/Interfaces/IDeleteSecretCommand.cs @@ -4,6 +4,6 @@ namespace Bit.Core.SecretsManager.Commands.Secrets.Interfaces; public interface IDeleteSecretCommand { - Task>> DeleteSecrets(List ids, Guid userId); + Task DeleteSecrets(IEnumerable secrets); } diff --git a/test/Api.IntegrationTest/SecretsManager/Controllers/SecretsControllerTests.cs b/test/Api.IntegrationTest/SecretsManager/Controllers/SecretsControllerTests.cs index 88b1a3350..b319b2859 100644 --- a/test/Api.IntegrationTest/SecretsManager/Controllers/SecretsControllerTests.cs +++ b/test/Api.IntegrationTest/SecretsManager/Controllers/SecretsControllerTests.cs @@ -644,10 +644,28 @@ public class SecretsControllerTests : IClassFixture, IAsy }); var secretIds = new[] { secret.Id }; - var response = await _client.PostAsJsonAsync($"/secrets/{org.Id}/delete", secretIds); + var response = await _client.PostAsJsonAsync($"/secrets/delete", secretIds); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } + [Fact] + public async Task Delete_MissingAccessPolicy_AccessDenied() + { + var (org, _) = await _organizationHelper.Initialize(true, true); + var (email, _) = await _organizationHelper.CreateNewUser(OrganizationUserType.User, true); + await LoginAsync(email); + + var (_, secretIds) = await CreateSecretsAsync(org.Id, 3); + + var response = await _client.PostAsync("/secrets/delete", JsonContent.Create(secretIds)); + + var results = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(results); + Assert.Equal(secretIds.OrderBy(x => x), + results!.Data.Select(x => x.Id).OrderBy(x => x)); + Assert.All(results.Data, item => Assert.Equal("access denied", item.Error)); + } + [Theory] [InlineData(PermissionType.RunAsAdmin)] [InlineData(PermissionType.RunAsUserWithPermission)] @@ -656,12 +674,7 @@ public class SecretsControllerTests : IClassFixture, IAsy var (org, _) = await _organizationHelper.Initialize(true, true); await LoginAsync(_email); - var project = await _projectRepository.CreateAsync(new Project() - { - Id = new Guid(), - OrganizationId = org.Id, - Name = _mockEncryptedString - }); + var (project, secretIds) = await CreateSecretsAsync(org.Id, 3); if (permissionType == PermissionType.RunAsUserWithPermission) { @@ -678,21 +691,6 @@ public class SecretsControllerTests : IClassFixture, IAsy await _accessPolicyRepository.CreateManyAsync(accessPolicies); } - - var secretIds = new List(); - for (var i = 0; i < 3; i++) - { - var secret = await _secretRepository.CreateAsync(new Secret - { - OrganizationId = org.Id, - Key = _mockEncryptedString, - Value = _mockEncryptedString, - Note = _mockEncryptedString, - Projects = new List() { project } - }); - secretIds.Add(secret.Id); - } - var response = await _client.PostAsJsonAsync($"/secrets/delete", secretIds); response.EnsureSuccessStatusCode(); @@ -710,4 +708,30 @@ public class SecretsControllerTests : IClassFixture, IAsy var secrets = await _secretRepository.GetManyByIds(secretIds); Assert.Empty(secrets); } + + private async Task<(Project Project, List secretIds)> CreateSecretsAsync(Guid orgId, int numberToCreate = 3) + { + var project = await _projectRepository.CreateAsync(new Project + { + Id = new Guid(), + OrganizationId = orgId, + Name = _mockEncryptedString + }); + + var secretIds = new List(); + for (var i = 0; i < numberToCreate; i++) + { + var secret = await _secretRepository.CreateAsync(new Secret + { + OrganizationId = orgId, + Key = _mockEncryptedString, + Value = _mockEncryptedString, + Note = _mockEncryptedString, + Projects = new List() { project } + }); + secretIds.Add(secret.Id); + } + + return (project, secretIds); + } } diff --git a/test/Api.Test/SecretsManager/Controllers/SecretsControllerTests.cs b/test/Api.Test/SecretsManager/Controllers/SecretsControllerTests.cs index dc677a19f..8afa2000a 100644 --- a/test/Api.Test/SecretsManager/Controllers/SecretsControllerTests.cs +++ b/test/Api.Test/SecretsManager/Controllers/SecretsControllerTests.cs @@ -244,45 +244,106 @@ public class SecretsControllerTests } [Theory] - [BitAutoData(PermissionType.RunAsAdmin)] - [BitAutoData(PermissionType.RunAsUserWithPermission)] - public async void BulkDeleteSecret_Success(PermissionType permissionType, SutProvider sutProvider, List data, Guid organizationId, Guid userId, Project mockProject) + [BitAutoData] + public async void BulkDelete_NoSecretsFound_ThrowsNotFound(SutProvider sutProvider, List data) { - sutProvider.GetDependency().GetProperUserId(default).ReturnsForAnyArgs(userId); - - if (permissionType == PermissionType.RunAsAdmin) - { - sutProvider.GetDependency().OrganizationAdmin(organizationId).Returns(true); - } - else - { - data.FirstOrDefault().Projects = new List() { mockProject }; - sutProvider.GetDependency().OrganizationAdmin(organizationId).Returns(false); - sutProvider.GetDependency().AccessToProjectAsync(default, default, default) - .Returns((true, true)); - } - - - var ids = data.Select(secret => secret.Id).ToList(); - var mockResult = new List>(); - - foreach (var secret in data) - { - mockResult.Add(new Tuple(secret, "")); - } - sutProvider.GetDependency().DeleteSecrets(ids, userId).ReturnsForAnyArgs(mockResult); - - var results = await sutProvider.Sut.BulkDeleteAsync(ids); - await sutProvider.GetDependency().Received(1) - .DeleteSecrets(Arg.Is(ids), userId); - Assert.Equal(data.Count, results.Data.Count()); + var ids = data.Select(s => s.Id).ToList(); + sutProvider.GetDependency().GetManyByIds(Arg.Is(ids)).ReturnsForAnyArgs(new List()); + await Assert.ThrowsAsync(() => sutProvider.Sut.BulkDeleteAsync(ids)); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().DeleteSecrets(Arg.Any>()); } [Theory] [BitAutoData] - public async void BulkDeleteSecret_NoGuids_ThrowsArgumentNullException(SutProvider sutProvider) + public async void BulkDelete_SecretsFoundMisMatch_ThrowsNotFound(SutProvider sutProvider, List data, Secret mockSecret) { - sutProvider.GetDependency().GetProperUserId(default).ReturnsForAnyArgs(new Guid()); - await Assert.ThrowsAsync(() => sutProvider.Sut.BulkDeleteAsync(new List())); + data.Add(mockSecret); + var ids = data.Select(s => s.Id).ToList(); + sutProvider.GetDependency().GetManyByIds(Arg.Is(ids)).ReturnsForAnyArgs(new List { mockSecret }); + await Assert.ThrowsAsync(() => sutProvider.Sut.BulkDeleteAsync(ids)); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().DeleteSecrets(Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async void BulkDelete_OrganizationMistMatch_ThrowsNotFound(SutProvider sutProvider, List data) + { + var ids = data.Select(s => s.Id).ToList(); + sutProvider.GetDependency().GetManyByIds(Arg.Is(ids)).ReturnsForAnyArgs(data); + await Assert.ThrowsAsync(() => sutProvider.Sut.BulkDeleteAsync(ids)); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().DeleteSecrets(Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async void BulkDelete_NoAccessToSecretsManager_ThrowsNotFound(SutProvider sutProvider, List data) + { + var ids = data.Select(s => s.Id).ToList(); + var organizationId = data.First().OrganizationId; + foreach (var s in data) + { + s.OrganizationId = organizationId; + } + sutProvider.GetDependency().AccessSecretsManager(Arg.Is(organizationId)).ReturnsForAnyArgs(false); + sutProvider.GetDependency().GetManyByIds(Arg.Is(ids)).ReturnsForAnyArgs(data); + await Assert.ThrowsAsync(() => sutProvider.Sut.BulkDeleteAsync(ids)); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().DeleteSecrets(Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async void BulkDelete_ReturnsAccessDeniedForSecretsWithoutAccess_Success(SutProvider sutProvider, List data) + { + var ids = data.Select(s => s.Id).ToList(); + var organizationId = data.First().OrganizationId; + foreach (var secret in data) + { + secret.OrganizationId = organizationId; + sutProvider.GetDependency() + .AuthorizeAsync(Arg.Any(), secret, + Arg.Any>()).ReturnsForAnyArgs(AuthorizationResult.Success()); + } + sutProvider.GetDependency() + .AuthorizeAsync(Arg.Any(), data.First(), + Arg.Any>()).Returns(AuthorizationResult.Failed()); + sutProvider.GetDependency().AccessSecretsManager(Arg.Is(organizationId)).ReturnsForAnyArgs(true); + sutProvider.GetDependency().GetManyByIds(Arg.Is(ids)).ReturnsForAnyArgs(data); + + var results = await sutProvider.Sut.BulkDeleteAsync(ids); + + Assert.Equal(data.Count, results.Data.Count()); + Assert.Equal("access denied", results.Data.First().Error); + + data.Remove(data.First()); + await sutProvider.GetDependency().Received(1) + .DeleteSecrets(Arg.Is(AssertHelper.AssertPropertyEqual(data))); + } + + [Theory] + [BitAutoData] + public async void BulkDelete_Success(SutProvider sutProvider, List data) + { + var ids = data.Select(sa => sa.Id).ToList(); + var organizationId = data.First().OrganizationId; + foreach (var secret in data) + { + secret.OrganizationId = organizationId; + sutProvider.GetDependency() + .AuthorizeAsync(Arg.Any(), secret, + Arg.Any>()).ReturnsForAnyArgs(AuthorizationResult.Success()); + } + + sutProvider.GetDependency().AccessSecretsManager(Arg.Is(organizationId)).ReturnsForAnyArgs(true); + sutProvider.GetDependency().GetManyByIds(Arg.Is(ids)).ReturnsForAnyArgs(data); + + var results = await sutProvider.Sut.BulkDeleteAsync(ids); + + await sutProvider.GetDependency().Received(1) + .DeleteSecrets(Arg.Is(AssertHelper.AssertPropertyEqual(data))); + Assert.Equal(data.Count, results.Data.Count()); + foreach (var result in results.Data) + { + Assert.Null(result.Error); + } } }