diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 8a7721bcb..7962215a4 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -32,6 +32,8 @@ using Bit.Core.Tools.Entities; using Bit.Core.Vault.Entities; using Bit.Api.Auth.Models.Request.WebAuthn; using Bit.Core.Auth.Models.Data; +using Bit.Core.Tools.ReportFeatures; + #if !OSS @@ -176,6 +178,7 @@ public class Startup services.AddOrganizationSubscriptionServices(); services.AddCoreLocalizationServices(); services.AddBillingOperations(); + services.AddReportingServices(); // Authorization Handlers services.AddAuthorizationHandlers(); diff --git a/src/Api/Tools/Controllers/ReportsController.cs b/src/Api/Tools/Controllers/ReportsController.cs index c8cfc0a21..9f465c7b8 100644 --- a/src/Api/Tools/Controllers/ReportsController.cs +++ b/src/Api/Tools/Controllers/ReportsController.cs @@ -1,9 +1,13 @@ -using Bit.Api.Tools.Models.Response; +using Bit.Api.Tools.Models; +using Bit.Api.Tools.Models.Response; using Bit.Core.Context; using Bit.Core.Exceptions; +using Bit.Core.Tools.Entities; using Bit.Core.Tools.Models.Data; +using Bit.Core.Tools.ReportFeatures.Interfaces; using Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces; using Bit.Core.Tools.ReportFeatures.Requests; +using Bit.Core.Tools.Requests; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -15,14 +19,20 @@ public class ReportsController : Controller { private readonly ICurrentContext _currentContext; private readonly IMemberAccessCipherDetailsQuery _memberAccessCipherDetailsQuery; + private readonly IAddPasswordHealthReportApplicationCommand _addPwdHealthReportAppCommand; + private readonly IGetPasswordHealthReportApplicationQuery _getPwdHealthReportAppQuery; public ReportsController( ICurrentContext currentContext, - IMemberAccessCipherDetailsQuery memberAccessCipherDetailsQuery + IMemberAccessCipherDetailsQuery memberAccessCipherDetailsQuery, + IAddPasswordHealthReportApplicationCommand addPasswordHealthReportApplicationCommand, + IGetPasswordHealthReportApplicationQuery getPasswordHealthReportApplicationQuery ) { _currentContext = currentContext; _memberAccessCipherDetailsQuery = memberAccessCipherDetailsQuery; + _addPwdHealthReportAppCommand = addPasswordHealthReportApplicationCommand; + _getPwdHealthReportAppQuery = getPasswordHealthReportApplicationQuery; } /// @@ -83,4 +93,72 @@ public class ReportsController : Controller await _memberAccessCipherDetailsQuery.GetMemberAccessCipherDetails(request); return memberCipherDetails; } + + /// + /// Get the password health report applications for an organization + /// + /// A valid Organization Id + /// An Enumerable of PasswordHealthReportApplication + /// If the user lacks access + /// If the organization Id is not valid + [HttpGet("password-health-report-applications/{orgId}")] + public async Task> GetPasswordHealthReportApplications(Guid orgId) + { + if (!await _currentContext.AccessReports(orgId)) + { + throw new NotFoundException(); + } + + return await _getPwdHealthReportAppQuery.GetPasswordHealthReportApplicationAsync(orgId); + } + + /// + /// Adds a new record into PasswordHealthReportApplication + /// + /// A single instance of PasswordHealthReportApplication Model + /// A single instance of PasswordHealthReportApplication + /// If the organization Id is not valid + /// If the user lacks access + [HttpPost("password-health-report-application")] + public async Task AddPasswordHealthReportApplication( + [FromBody] PasswordHealthReportApplicationModel request) + { + if (!await _currentContext.AccessReports(request.OrganizationId)) + { + throw new NotFoundException(); + } + + var commandRequest = new AddPasswordHealthReportApplicationRequest + { + OrganizationId = request.OrganizationId, + Url = request.Url + }; + + return await _addPwdHealthReportAppCommand.AddPasswordHealthReportApplicationAsync(commandRequest); + } + + /// + /// Adds multiple records into PasswordHealthReportApplication + /// + /// A enumerable of PasswordHealthReportApplicationModel + /// An Enumerable of PasswordHealthReportApplication + /// If user does not have access to the OrganizationId + /// If the organization Id is not valid + [HttpPost("password-health-report-applications")] + public async Task> AddPasswordHealthReportApplications( + [FromBody] IEnumerable request) + { + if (request.Any(_ => _currentContext.AccessReports(_.OrganizationId).Result == false)) + { + throw new NotFoundException(); + } + + var commandRequests = request.Select(request => new AddPasswordHealthReportApplicationRequest + { + OrganizationId = request.OrganizationId, + Url = request.Url + }).ToList(); + + return await _addPwdHealthReportAppCommand.AddPasswordHealthReportApplicationAsync(commandRequests); + } } diff --git a/src/Api/Tools/Models/PasswordHealthReportApplicationModel.cs b/src/Api/Tools/Models/PasswordHealthReportApplicationModel.cs new file mode 100644 index 000000000..93467e117 --- /dev/null +++ b/src/Api/Tools/Models/PasswordHealthReportApplicationModel.cs @@ -0,0 +1,7 @@ +namespace Bit.Api.Tools.Models; + +public class PasswordHealthReportApplicationModel +{ + public Guid OrganizationId { get; set; } + public string Url { get; set; } +} diff --git a/src/Core/Tools/ReportFeatures/AddPasswordHealthReportApplicationCommand.cs b/src/Core/Tools/ReportFeatures/AddPasswordHealthReportApplicationCommand.cs new file mode 100644 index 000000000..c6bdb4417 --- /dev/null +++ b/src/Core/Tools/ReportFeatures/AddPasswordHealthReportApplicationCommand.cs @@ -0,0 +1,101 @@ +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Tools.Entities; +using Bit.Core.Tools.ReportFeatures.Interfaces; +using Bit.Core.Tools.Repositories; +using Bit.Core.Tools.Requests; + +namespace Bit.Core.Tools.ReportFeatures; + +public class AddPasswordHealthReportApplicationCommand : IAddPasswordHealthReportApplicationCommand +{ + private IOrganizationRepository _organizationRepo; + private IPasswordHealthReportApplicationRepository _passwordHealthReportApplicationRepo; + + public AddPasswordHealthReportApplicationCommand( + IOrganizationRepository organizationRepository, + IPasswordHealthReportApplicationRepository passwordHealthReportApplicationRepository) + { + _organizationRepo = organizationRepository; + _passwordHealthReportApplicationRepo = passwordHealthReportApplicationRepository; + } + + public async Task AddPasswordHealthReportApplicationAsync(AddPasswordHealthReportApplicationRequest request) + { + var (req, IsValid, errorMessage) = await ValidateRequestAsync(request); + if (!IsValid) + { + throw new BadRequestException(errorMessage); + } + + var passwordHealthReportApplication = new PasswordHealthReportApplication + { + OrganizationId = request.OrganizationId, + Uri = request.Url, + }; + + passwordHealthReportApplication.SetNewId(); + + var data = await _passwordHealthReportApplicationRepo.CreateAsync(passwordHealthReportApplication); + return data; + } + + public async Task> AddPasswordHealthReportApplicationAsync(IEnumerable requests) + { + var requestsList = requests.ToList(); + + // create tasks to validate each request + var tasks = requestsList.Select(async request => + { + var (req, IsValid, errorMessage) = await ValidateRequestAsync(request); + if (!IsValid) + { + throw new BadRequestException(errorMessage); + } + }); + + // run validations and allow exceptions to bubble + await Task.WhenAll(tasks); + + // create PasswordHealthReportApplication entities + var passwordHealthReportApplications = requestsList.Select(request => + { + var pwdHealthReportApplication = new PasswordHealthReportApplication + { + OrganizationId = request.OrganizationId, + Uri = request.Url, + }; + pwdHealthReportApplication.SetNewId(); + return pwdHealthReportApplication; + }); + + // create and return the entities + var response = new List(); + foreach (var record in passwordHealthReportApplications) + { + var data = await _passwordHealthReportApplicationRepo.CreateAsync(record); + response.Add(data); + } + + return response; + } + + private async Task> ValidateRequestAsync( + AddPasswordHealthReportApplicationRequest request) + { + // verify that the organization exists + var organization = await _organizationRepo.GetByIdAsync(request.OrganizationId); + if (organization == null) + { + return new Tuple(request, false, "Invalid Organization"); + } + + // ensure that we have a URL + if (string.IsNullOrWhiteSpace(request.Url)) + { + return new Tuple(request, false, "URL is required"); + } + + return new Tuple(request, true, string.Empty); + } +} diff --git a/src/Core/Tools/ReportFeatures/GetPasswordHealthReportApplicationQuery.cs b/src/Core/Tools/ReportFeatures/GetPasswordHealthReportApplicationQuery.cs new file mode 100644 index 000000000..5baf5b2f7 --- /dev/null +++ b/src/Core/Tools/ReportFeatures/GetPasswordHealthReportApplicationQuery.cs @@ -0,0 +1,27 @@ +using Bit.Core.Exceptions; +using Bit.Core.Tools.Entities; +using Bit.Core.Tools.ReportFeatures.Interfaces; +using Bit.Core.Tools.Repositories; + +namespace Bit.Core.Tools.ReportFeatures; + +public class GetPasswordHealthReportApplicationQuery : IGetPasswordHealthReportApplicationQuery +{ + private IPasswordHealthReportApplicationRepository _passwordHealthReportApplicationRepo; + + public GetPasswordHealthReportApplicationQuery( + IPasswordHealthReportApplicationRepository passwordHealthReportApplicationRepo) + { + _passwordHealthReportApplicationRepo = passwordHealthReportApplicationRepo; + } + + public async Task> GetPasswordHealthReportApplicationAsync(Guid organizationId) + { + if (organizationId == Guid.Empty) + { + throw new BadRequestException("OrganizationId is required."); + } + + return await _passwordHealthReportApplicationRepo.GetByOrganizationIdAsync(organizationId); + } +} diff --git a/src/Core/Tools/ReportFeatures/Interfaces/IAddPasswordHealthReportApplicationCommand.cs b/src/Core/Tools/ReportFeatures/Interfaces/IAddPasswordHealthReportApplicationCommand.cs new file mode 100644 index 000000000..86e7d44c7 --- /dev/null +++ b/src/Core/Tools/ReportFeatures/Interfaces/IAddPasswordHealthReportApplicationCommand.cs @@ -0,0 +1,10 @@ +using Bit.Core.Tools.Entities; +using Bit.Core.Tools.Requests; + +namespace Bit.Core.Tools.ReportFeatures.Interfaces; + +public interface IAddPasswordHealthReportApplicationCommand +{ + Task AddPasswordHealthReportApplicationAsync(AddPasswordHealthReportApplicationRequest request); + Task> AddPasswordHealthReportApplicationAsync(IEnumerable requests); +} diff --git a/src/Core/Tools/ReportFeatures/Interfaces/IGetPasswordHealthReportApplicationQuery.cs b/src/Core/Tools/ReportFeatures/Interfaces/IGetPasswordHealthReportApplicationQuery.cs new file mode 100644 index 000000000..f24119c2b --- /dev/null +++ b/src/Core/Tools/ReportFeatures/Interfaces/IGetPasswordHealthReportApplicationQuery.cs @@ -0,0 +1,8 @@ +using Bit.Core.Tools.Entities; + +namespace Bit.Core.Tools.ReportFeatures.Interfaces; + +public interface IGetPasswordHealthReportApplicationQuery +{ + Task> GetPasswordHealthReportApplicationAsync(Guid organizationId); +} diff --git a/src/Core/Tools/ReportFeatures/ReportingServiceCollectionExtensions.cs b/src/Core/Tools/ReportFeatures/ReportingServiceCollectionExtensions.cs index 5c813b8cb..85e5388a0 100644 --- a/src/Core/Tools/ReportFeatures/ReportingServiceCollectionExtensions.cs +++ b/src/Core/Tools/ReportFeatures/ReportingServiceCollectionExtensions.cs @@ -1,4 +1,4 @@ - +using Bit.Core.Tools.ReportFeatures.Interfaces; using Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces; using Microsoft.Extensions.DependencyInjection; @@ -9,5 +9,7 @@ public static class ReportingServiceCollectionExtensions public static void AddReportingServices(this IServiceCollection services) { services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } } diff --git a/src/Core/Tools/Requests/AddPasswordHealthReportApplicationRequest.cs b/src/Core/Tools/Requests/AddPasswordHealthReportApplicationRequest.cs new file mode 100644 index 000000000..f5a1d35ea --- /dev/null +++ b/src/Core/Tools/Requests/AddPasswordHealthReportApplicationRequest.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Tools.Requests; + +public class AddPasswordHealthReportApplicationRequest +{ + public Guid OrganizationId { get; set; } + public string Url { get; set; } +} diff --git a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs index ad0b46277..0e33895bf 100644 --- a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs +++ b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs @@ -95,6 +95,7 @@ public static class EntityFrameworkServiceCollectionExtensions services.AddSingleton(); services .AddSingleton(); + services.AddSingleton(); if (selfHosted) { diff --git a/test/Api.Test/Tools/Controllers/ReportsControllerTests.cs b/test/Api.Test/Tools/Controllers/ReportsControllerTests.cs new file mode 100644 index 000000000..07d37f672 --- /dev/null +++ b/test/Api.Test/Tools/Controllers/ReportsControllerTests.cs @@ -0,0 +1,49 @@ +using Bit.Api.Tools.Controllers; +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Tools.ReportFeatures.Interfaces; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.Tools.Controllers; + + +[ControllerCustomize(typeof(ReportsController))] +[SutProviderCustomize] +public class ReportsControllerTests +{ + [Theory, BitAutoData] + public async Task GetPasswordHealthReportApplicationAsync_Success(SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency().AccessReports(Arg.Any()).Returns(true); + + // Act + var orgId = Guid.NewGuid(); + var result = await sutProvider.Sut.GetPasswordHealthReportApplications(orgId); + + // Assert + _ = sutProvider.GetDependency() + .Received(1) + .GetPasswordHealthReportApplicationAsync(Arg.Is(_ => _ == orgId)); + } + + [Theory, BitAutoData] + public async Task GetPasswordHealthReportApplicationAsync_withoutAccess(SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency().AccessReports(Arg.Any()).Returns(false); + + // Act & Assert + var orgId = Guid.NewGuid(); + await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetPasswordHealthReportApplications(orgId)); + + // Assert + _ = sutProvider.GetDependency() + .Received(0); + } + + +} diff --git a/test/Core.Test/Tools/ReportFeatures/AddPasswordHealthReportApplicationCommandTests.cs b/test/Core.Test/Tools/ReportFeatures/AddPasswordHealthReportApplicationCommandTests.cs new file mode 100644 index 000000000..8c3a68fdc --- /dev/null +++ b/test/Core.Test/Tools/ReportFeatures/AddPasswordHealthReportApplicationCommandTests.cs @@ -0,0 +1,149 @@ +using AutoFixture; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Tools.Entities; +using Bit.Core.Tools.ReportFeatures; +using Bit.Core.Tools.Repositories; +using Bit.Core.Tools.Requests; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Tools.ReportFeatures; + +[SutProviderCustomize] +public class AddPasswordHealthReportApplicationCommandTests +{ + [Theory] + [BitAutoData] + public async Task AddPasswordHealthReportApplicationAsync_WithValidRequest_ShouldReturnPasswordHealthReportApplication( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(fixture.Create()); + + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Returns(c => c.Arg()); + + // Act + var result = await sutProvider.Sut.AddPasswordHealthReportApplicationAsync(request); + + // Assert + Assert.NotNull(result); + } + + [Theory] + [BitAutoData] + public async Task AddPasswordHealthReportApplicationAsync_WithInvalidOrganizationId_ShouldThrowError( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(null as Organization); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.AddPasswordHealthReportApplicationAsync(request)); + Assert.Equal("Invalid Organization", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task AddPasswordHealthReportApplicationAsync_WithInvalidUrl_ShouldThrowError( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .Without(_ => _.Url) + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(fixture.Create()); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.AddPasswordHealthReportApplicationAsync(request)); + Assert.Equal("URL is required", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task AddPasswordHealthReportApplicationAsync_Multiples_WithInvalidOrganizationId_ShouldThrowError( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .Without(_ => _.OrganizationId) + .CreateMany(2).ToList(); + + request[0].OrganizationId = Guid.NewGuid(); + request[1].OrganizationId = Guid.Empty; + + // only return an organization for the first request and null for the second + sutProvider.GetDependency() + .GetByIdAsync(Arg.Is(x => x == request[0].OrganizationId)) + .Returns(fixture.Create()); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.AddPasswordHealthReportApplicationAsync(request)); + Assert.Equal("Invalid Organization", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task AddPasswordHealthReportApplicationAsync_Multiples_WithInvalidUrl_ShouldThrowError( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .CreateMany(2).ToList(); + + request[1].Url = string.Empty; + + // return an organization for both requests + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(fixture.Create()); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.AddPasswordHealthReportApplicationAsync(request)); + Assert.Equal("URL is required", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task AddPasswordHealthReportApplicationAsync_Multiples_WithValidRequest_ShouldBeSuccessful( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.CreateMany(2); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(fixture.Create()); + + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Returns(c => c.Arg()); + + // Act + var result = await sutProvider.Sut.AddPasswordHealthReportApplicationAsync(request); + + // Assert + Assert.True(result.Count() == 2); + sutProvider.GetDependency().Received(2); + sutProvider.GetDependency().Received(2); + } +} diff --git a/test/Core.Test/Tools/ReportFeatures/GetPasswordHealthReportApplicationQueryTests.cs b/test/Core.Test/Tools/ReportFeatures/GetPasswordHealthReportApplicationQueryTests.cs new file mode 100644 index 000000000..c4f098b0c --- /dev/null +++ b/test/Core.Test/Tools/ReportFeatures/GetPasswordHealthReportApplicationQueryTests.cs @@ -0,0 +1,53 @@ +using AutoFixture; +using Bit.Core.Exceptions; +using Bit.Core.Tools.Entities; +using Bit.Core.Tools.ReportFeatures; +using Bit.Core.Tools.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Tools.ReportFeatures; + +[SutProviderCustomize] +public class GetPasswordHealthReportApplicationQueryTests +{ + [Theory] + [BitAutoData] + public async Task GetPasswordHealthReportApplicationAsync_WithValidOrganizationId_ShouldReturnPasswordHealthReportApplication( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var organizationId = fixture.Create(); + sutProvider.GetDependency() + .GetByOrganizationIdAsync(Arg.Any()) + .Returns(fixture.CreateMany(2).ToList()); + + // Act + var result = await sutProvider.Sut.GetPasswordHealthReportApplicationAsync(organizationId); + + // Assert + Assert.NotNull(result); + Assert.True(result.Count() == 2); + } + + [Theory] + [BitAutoData] + public async Task GetPasswordHealthReportApplicationAsync_WithInvalidOrganizationId_ShouldFail( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + sutProvider.GetDependency() + .GetByOrganizationIdAsync(Arg.Is(x => x == Guid.Empty)) + .Returns(new List()); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetPasswordHealthReportApplicationAsync(Guid.Empty)); + + // Assert + Assert.Equal("OrganizationId is required.", exception.Message); + } +}