1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-21 12:05:42 +01:00

PM-13237 password health report application add get (#5000)

* PM-13236 PasswordHealthReportApplications db

* PM-13236 incorporated pr comments

* PM-13236 fixed error in SQL script

* PM-13236 resolve quality scan errors SQL71006, SQL7101, SQL70001

* PM-13236 fixed warnings on procedures

* PM-13236 added efMigrations

* PM-13236 renamed files to PasswordHealthReportApplication (singular)

* PM-13236 changed file name to more appropriate naming

* PM-13236 changed the file name singular

* PM-13236 PasswordHealthReportApplication Entities and Repos

* PM-13236 moved files under tools from core

* PM-13236 Entity PasswordHealthReportApplication namespace changed to tools/entities

* PM-13236 moved Repos and Interfaces to tools

* PM-13236 migrated model to tools namespace

* PM-13236 minor fixes to the unit tests

* PM-13236 fixed script errors during build

* PM-13236 Script to drop PasswordHealthReportApplications if it exists

* PM-13236 fixes to database snapshot

* PM-13236 updated databasesnapshots

* PM-13236 Update database model changes for Mysql

* PM-13236 update model changes for Sqlite

* PM-13236 updated the models to remove commented code

* PM-13236 added correct db snapshot for MySql

* PM-13236 updated database snapshot for Postgres

* PM-13236 updated database snapshot for Sqlite

* PM-13236 removed unwanted directive to fix linting error

* PM-13236 removed redundant script files

* PM-13237 Add entity command and unit tests

* PM-13237 Get query added with unit tests

* PM-13237 Controller to add/get PasswordHealthReportApplication

* PM-13237 Setup dependencies in the EF Service collection extensions

* PM-13237 Added unit tests for ReportsController
This commit is contained in:
Vijay Oommen 2024-11-11 11:54:52 -06:00 committed by GitHub
parent 0e23a07bbc
commit 9fb3f1d346
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 498 additions and 3 deletions

View File

@ -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();

View File

@ -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;
}
/// <summary>
@ -83,4 +93,72 @@ public class ReportsController : Controller
await _memberAccessCipherDetailsQuery.GetMemberAccessCipherDetails(request);
return memberCipherDetails;
}
/// <summary>
/// Get the password health report applications for an organization
/// </summary>
/// <param name="orgId">A valid Organization Id</param>
/// <returns>An Enumerable of PasswordHealthReportApplication </returns>
/// <exception cref="NotFoundException">If the user lacks access</exception>
/// <exception cref="BadRequestException">If the organization Id is not valid</exception>
[HttpGet("password-health-report-applications/{orgId}")]
public async Task<IEnumerable<PasswordHealthReportApplication>> GetPasswordHealthReportApplications(Guid orgId)
{
if (!await _currentContext.AccessReports(orgId))
{
throw new NotFoundException();
}
return await _getPwdHealthReportAppQuery.GetPasswordHealthReportApplicationAsync(orgId);
}
/// <summary>
/// Adds a new record into PasswordHealthReportApplication
/// </summary>
/// <param name="request">A single instance of PasswordHealthReportApplication Model</param>
/// <returns>A single instance of PasswordHealthReportApplication</returns>
/// <exception cref="BadRequestException">If the organization Id is not valid</exception>
/// <exception cref="NotFoundException">If the user lacks access</exception>
[HttpPost("password-health-report-application")]
public async Task<PasswordHealthReportApplication> 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);
}
/// <summary>
/// Adds multiple records into PasswordHealthReportApplication
/// </summary>
/// <param name="request">A enumerable of PasswordHealthReportApplicationModel</param>
/// <returns>An Enumerable of PasswordHealthReportApplication</returns>
/// <exception cref="NotFoundException">If user does not have access to the OrganizationId</exception>
/// <exception cref="BadRequestException">If the organization Id is not valid</exception>
[HttpPost("password-health-report-applications")]
public async Task<IEnumerable<PasswordHealthReportApplication>> AddPasswordHealthReportApplications(
[FromBody] IEnumerable<PasswordHealthReportApplicationModel> 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);
}
}

View File

@ -0,0 +1,7 @@
namespace Bit.Api.Tools.Models;
public class PasswordHealthReportApplicationModel
{
public Guid OrganizationId { get; set; }
public string Url { get; set; }
}

View File

@ -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<PasswordHealthReportApplication> 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<IEnumerable<PasswordHealthReportApplication>> AddPasswordHealthReportApplicationAsync(IEnumerable<AddPasswordHealthReportApplicationRequest> 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<PasswordHealthReportApplication>();
foreach (var record in passwordHealthReportApplications)
{
var data = await _passwordHealthReportApplicationRepo.CreateAsync(record);
response.Add(data);
}
return response;
}
private async Task<Tuple<AddPasswordHealthReportApplicationRequest, bool, string>> ValidateRequestAsync(
AddPasswordHealthReportApplicationRequest request)
{
// verify that the organization exists
var organization = await _organizationRepo.GetByIdAsync(request.OrganizationId);
if (organization == null)
{
return new Tuple<AddPasswordHealthReportApplicationRequest, bool, string>(request, false, "Invalid Organization");
}
// ensure that we have a URL
if (string.IsNullOrWhiteSpace(request.Url))
{
return new Tuple<AddPasswordHealthReportApplicationRequest, bool, string>(request, false, "URL is required");
}
return new Tuple<AddPasswordHealthReportApplicationRequest, bool, string>(request, true, string.Empty);
}
}

View File

@ -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<IEnumerable<PasswordHealthReportApplication>> GetPasswordHealthReportApplicationAsync(Guid organizationId)
{
if (organizationId == Guid.Empty)
{
throw new BadRequestException("OrganizationId is required.");
}
return await _passwordHealthReportApplicationRepo.GetByOrganizationIdAsync(organizationId);
}
}

View File

@ -0,0 +1,10 @@
using Bit.Core.Tools.Entities;
using Bit.Core.Tools.Requests;
namespace Bit.Core.Tools.ReportFeatures.Interfaces;
public interface IAddPasswordHealthReportApplicationCommand
{
Task<PasswordHealthReportApplication> AddPasswordHealthReportApplicationAsync(AddPasswordHealthReportApplicationRequest request);
Task<IEnumerable<PasswordHealthReportApplication>> AddPasswordHealthReportApplicationAsync(IEnumerable<AddPasswordHealthReportApplicationRequest> requests);
}

View File

@ -0,0 +1,8 @@
using Bit.Core.Tools.Entities;
namespace Bit.Core.Tools.ReportFeatures.Interfaces;
public interface IGetPasswordHealthReportApplicationQuery
{
Task<IEnumerable<PasswordHealthReportApplication>> GetPasswordHealthReportApplicationAsync(Guid organizationId);
}

View File

@ -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<IMemberAccessCipherDetailsQuery, MemberAccessCipherDetailsQuery>();
services.AddScoped<IAddPasswordHealthReportApplicationCommand, AddPasswordHealthReportApplicationCommand>();
services.AddScoped<IGetPasswordHealthReportApplicationQuery, GetPasswordHealthReportApplicationQuery>();
}
}

View File

@ -0,0 +1,7 @@
namespace Bit.Core.Tools.Requests;
public class AddPasswordHealthReportApplicationRequest
{
public Guid OrganizationId { get; set; }
public string Url { get; set; }
}

View File

@ -95,6 +95,7 @@ public static class EntityFrameworkServiceCollectionExtensions
services.AddSingleton<INotificationStatusRepository, NotificationStatusRepository>();
services
.AddSingleton<IClientOrganizationMigrationRecordRepository, ClientOrganizationMigrationRecordRepository>();
services.AddSingleton<IPasswordHealthReportApplicationRepository, PasswordHealthReportApplicationRepository>();
if (selfHosted)
{

View File

@ -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<ReportsController> sutProvider)
{
// Arrange
sutProvider.GetDependency<ICurrentContext>().AccessReports(Arg.Any<Guid>()).Returns(true);
// Act
var orgId = Guid.NewGuid();
var result = await sutProvider.Sut.GetPasswordHealthReportApplications(orgId);
// Assert
_ = sutProvider.GetDependency<IGetPasswordHealthReportApplicationQuery>()
.Received(1)
.GetPasswordHealthReportApplicationAsync(Arg.Is<Guid>(_ => _ == orgId));
}
[Theory, BitAutoData]
public async Task GetPasswordHealthReportApplicationAsync_withoutAccess(SutProvider<ReportsController> sutProvider)
{
// Arrange
sutProvider.GetDependency<ICurrentContext>().AccessReports(Arg.Any<Guid>()).Returns(false);
// Act & Assert
var orgId = Guid.NewGuid();
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.GetPasswordHealthReportApplications(orgId));
// Assert
_ = sutProvider.GetDependency<IGetPasswordHealthReportApplicationQuery>()
.Received(0);
}
}

View File

@ -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<AddPasswordHealthReportApplicationCommand> sutProvider)
{
// Arrange
var fixture = new Fixture();
var request = fixture.Create<AddPasswordHealthReportApplicationRequest>();
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(fixture.Create<Organization>());
sutProvider.GetDependency<IPasswordHealthReportApplicationRepository>()
.CreateAsync(Arg.Any<PasswordHealthReportApplication>())
.Returns(c => c.Arg<PasswordHealthReportApplication>());
// Act
var result = await sutProvider.Sut.AddPasswordHealthReportApplicationAsync(request);
// Assert
Assert.NotNull(result);
}
[Theory]
[BitAutoData]
public async Task AddPasswordHealthReportApplicationAsync_WithInvalidOrganizationId_ShouldThrowError(
SutProvider<AddPasswordHealthReportApplicationCommand> sutProvider)
{
// Arrange
var fixture = new Fixture();
var request = fixture.Create<AddPasswordHealthReportApplicationRequest>();
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(null as Organization);
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.AddPasswordHealthReportApplicationAsync(request));
Assert.Equal("Invalid Organization", exception.Message);
}
[Theory]
[BitAutoData]
public async Task AddPasswordHealthReportApplicationAsync_WithInvalidUrl_ShouldThrowError(
SutProvider<AddPasswordHealthReportApplicationCommand> sutProvider)
{
// Arrange
var fixture = new Fixture();
var request = fixture.Build<AddPasswordHealthReportApplicationRequest>()
.Without(_ => _.Url)
.Create();
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(fixture.Create<Organization>());
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.AddPasswordHealthReportApplicationAsync(request));
Assert.Equal("URL is required", exception.Message);
}
[Theory]
[BitAutoData]
public async Task AddPasswordHealthReportApplicationAsync_Multiples_WithInvalidOrganizationId_ShouldThrowError(
SutProvider<AddPasswordHealthReportApplicationCommand> sutProvider)
{
// Arrange
var fixture = new Fixture();
var request = fixture.Build<AddPasswordHealthReportApplicationRequest>()
.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<IOrganizationRepository>()
.GetByIdAsync(Arg.Is<Guid>(x => x == request[0].OrganizationId))
.Returns(fixture.Create<Organization>());
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.AddPasswordHealthReportApplicationAsync(request));
Assert.Equal("Invalid Organization", exception.Message);
}
[Theory]
[BitAutoData]
public async Task AddPasswordHealthReportApplicationAsync_Multiples_WithInvalidUrl_ShouldThrowError(
SutProvider<AddPasswordHealthReportApplicationCommand> sutProvider)
{
// Arrange
var fixture = new Fixture();
var request = fixture.Build<AddPasswordHealthReportApplicationRequest>()
.CreateMany(2).ToList();
request[1].Url = string.Empty;
// return an organization for both requests
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(fixture.Create<Organization>());
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.AddPasswordHealthReportApplicationAsync(request));
Assert.Equal("URL is required", exception.Message);
}
[Theory]
[BitAutoData]
public async Task AddPasswordHealthReportApplicationAsync_Multiples_WithValidRequest_ShouldBeSuccessful(
SutProvider<AddPasswordHealthReportApplicationCommand> sutProvider)
{
// Arrange
var fixture = new Fixture();
var request = fixture.CreateMany<AddPasswordHealthReportApplicationRequest>(2);
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(fixture.Create<Organization>());
sutProvider.GetDependency<IPasswordHealthReportApplicationRepository>()
.CreateAsync(Arg.Any<PasswordHealthReportApplication>())
.Returns(c => c.Arg<PasswordHealthReportApplication>());
// Act
var result = await sutProvider.Sut.AddPasswordHealthReportApplicationAsync(request);
// Assert
Assert.True(result.Count() == 2);
sutProvider.GetDependency<IOrganizationRepository>().Received(2);
sutProvider.GetDependency<IPasswordHealthReportApplicationRepository>().Received(2);
}
}

View File

@ -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<GetPasswordHealthReportApplicationQuery> sutProvider)
{
// Arrange
var fixture = new Fixture();
var organizationId = fixture.Create<Guid>();
sutProvider.GetDependency<IPasswordHealthReportApplicationRepository>()
.GetByOrganizationIdAsync(Arg.Any<Guid>())
.Returns(fixture.CreateMany<PasswordHealthReportApplication>(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<GetPasswordHealthReportApplicationQuery> sutProvider)
{
// Arrange
var fixture = new Fixture();
sutProvider.GetDependency<IPasswordHealthReportApplicationRepository>()
.GetByOrganizationIdAsync(Arg.Is<Guid>(x => x == Guid.Empty))
.Returns(new List<PasswordHealthReportApplication>());
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.GetPasswordHealthReportApplicationAsync(Guid.Empty));
// Assert
Assert.Equal("OrganizationId is required.", exception.Message);
}
}