Skip to content

Commit bc67f0f

Browse files
voommen-livefrontvgrassia
authored andcommitted
PM-13237 password health report application add get (bitwarden#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
1 parent 7bf3591 commit bc67f0f

13 files changed

+498
-3
lines changed

src/Api/Startup.cs

+3
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
using Bit.Core.Vault.Entities;
3333
using Bit.Api.Auth.Models.Request.WebAuthn;
3434
using Bit.Core.Auth.Models.Data;
35+
using Bit.Core.Tools.ReportFeatures;
36+
3537

3638

3739
#if !OSS
@@ -176,6 +178,7 @@ public void ConfigureServices(IServiceCollection services)
176178
services.AddOrganizationSubscriptionServices();
177179
services.AddCoreLocalizationServices();
178180
services.AddBillingOperations();
181+
services.AddReportingServices();
179182

180183
// Authorization Handlers
181184
services.AddAuthorizationHandlers();

src/Api/Tools/Controllers/ReportsController.cs

+80-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1-
using Bit.Api.Tools.Models.Response;
1+
using Bit.Api.Tools.Models;
2+
using Bit.Api.Tools.Models.Response;
23
using Bit.Core.Context;
34
using Bit.Core.Exceptions;
5+
using Bit.Core.Tools.Entities;
46
using Bit.Core.Tools.Models.Data;
7+
using Bit.Core.Tools.ReportFeatures.Interfaces;
58
using Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces;
69
using Bit.Core.Tools.ReportFeatures.Requests;
10+
using Bit.Core.Tools.Requests;
711
using Microsoft.AspNetCore.Authorization;
812
using Microsoft.AspNetCore.Mvc;
913

@@ -15,14 +19,20 @@ public class ReportsController : Controller
1519
{
1620
private readonly ICurrentContext _currentContext;
1721
private readonly IMemberAccessCipherDetailsQuery _memberAccessCipherDetailsQuery;
22+
private readonly IAddPasswordHealthReportApplicationCommand _addPwdHealthReportAppCommand;
23+
private readonly IGetPasswordHealthReportApplicationQuery _getPwdHealthReportAppQuery;
1824

1925
public ReportsController(
2026
ICurrentContext currentContext,
21-
IMemberAccessCipherDetailsQuery memberAccessCipherDetailsQuery
27+
IMemberAccessCipherDetailsQuery memberAccessCipherDetailsQuery,
28+
IAddPasswordHealthReportApplicationCommand addPasswordHealthReportApplicationCommand,
29+
IGetPasswordHealthReportApplicationQuery getPasswordHealthReportApplicationQuery
2230
)
2331
{
2432
_currentContext = currentContext;
2533
_memberAccessCipherDetailsQuery = memberAccessCipherDetailsQuery;
34+
_addPwdHealthReportAppCommand = addPasswordHealthReportApplicationCommand;
35+
_getPwdHealthReportAppQuery = getPasswordHealthReportApplicationQuery;
2636
}
2737

2838
/// <summary>
@@ -83,4 +93,72 @@ private async Task<IEnumerable<MemberAccessCipherDetails>> GetMemberCipherDetail
8393
await _memberAccessCipherDetailsQuery.GetMemberAccessCipherDetails(request);
8494
return memberCipherDetails;
8595
}
96+
97+
/// <summary>
98+
/// Get the password health report applications for an organization
99+
/// </summary>
100+
/// <param name="orgId">A valid Organization Id</param>
101+
/// <returns>An Enumerable of PasswordHealthReportApplication </returns>
102+
/// <exception cref="NotFoundException">If the user lacks access</exception>
103+
/// <exception cref="BadRequestException">If the organization Id is not valid</exception>
104+
[HttpGet("password-health-report-applications/{orgId}")]
105+
public async Task<IEnumerable<PasswordHealthReportApplication>> GetPasswordHealthReportApplications(Guid orgId)
106+
{
107+
if (!await _currentContext.AccessReports(orgId))
108+
{
109+
throw new NotFoundException();
110+
}
111+
112+
return await _getPwdHealthReportAppQuery.GetPasswordHealthReportApplicationAsync(orgId);
113+
}
114+
115+
/// <summary>
116+
/// Adds a new record into PasswordHealthReportApplication
117+
/// </summary>
118+
/// <param name="request">A single instance of PasswordHealthReportApplication Model</param>
119+
/// <returns>A single instance of PasswordHealthReportApplication</returns>
120+
/// <exception cref="BadRequestException">If the organization Id is not valid</exception>
121+
/// <exception cref="NotFoundException">If the user lacks access</exception>
122+
[HttpPost("password-health-report-application")]
123+
public async Task<PasswordHealthReportApplication> AddPasswordHealthReportApplication(
124+
[FromBody] PasswordHealthReportApplicationModel request)
125+
{
126+
if (!await _currentContext.AccessReports(request.OrganizationId))
127+
{
128+
throw new NotFoundException();
129+
}
130+
131+
var commandRequest = new AddPasswordHealthReportApplicationRequest
132+
{
133+
OrganizationId = request.OrganizationId,
134+
Url = request.Url
135+
};
136+
137+
return await _addPwdHealthReportAppCommand.AddPasswordHealthReportApplicationAsync(commandRequest);
138+
}
139+
140+
/// <summary>
141+
/// Adds multiple records into PasswordHealthReportApplication
142+
/// </summary>
143+
/// <param name="request">A enumerable of PasswordHealthReportApplicationModel</param>
144+
/// <returns>An Enumerable of PasswordHealthReportApplication</returns>
145+
/// <exception cref="NotFoundException">If user does not have access to the OrganizationId</exception>
146+
/// <exception cref="BadRequestException">If the organization Id is not valid</exception>
147+
[HttpPost("password-health-report-applications")]
148+
public async Task<IEnumerable<PasswordHealthReportApplication>> AddPasswordHealthReportApplications(
149+
[FromBody] IEnumerable<PasswordHealthReportApplicationModel> request)
150+
{
151+
if (request.Any(_ => _currentContext.AccessReports(_.OrganizationId).Result == false))
152+
{
153+
throw new NotFoundException();
154+
}
155+
156+
var commandRequests = request.Select(request => new AddPasswordHealthReportApplicationRequest
157+
{
158+
OrganizationId = request.OrganizationId,
159+
Url = request.Url
160+
}).ToList();
161+
162+
return await _addPwdHealthReportAppCommand.AddPasswordHealthReportApplicationAsync(commandRequests);
163+
}
86164
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace Bit.Api.Tools.Models;
2+
3+
public class PasswordHealthReportApplicationModel
4+
{
5+
public Guid OrganizationId { get; set; }
6+
public string Url { get; set; }
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
using Bit.Core.Exceptions;
2+
using Bit.Core.Repositories;
3+
using Bit.Core.Tools.Entities;
4+
using Bit.Core.Tools.ReportFeatures.Interfaces;
5+
using Bit.Core.Tools.Repositories;
6+
using Bit.Core.Tools.Requests;
7+
8+
namespace Bit.Core.Tools.ReportFeatures;
9+
10+
public class AddPasswordHealthReportApplicationCommand : IAddPasswordHealthReportApplicationCommand
11+
{
12+
private IOrganizationRepository _organizationRepo;
13+
private IPasswordHealthReportApplicationRepository _passwordHealthReportApplicationRepo;
14+
15+
public AddPasswordHealthReportApplicationCommand(
16+
IOrganizationRepository organizationRepository,
17+
IPasswordHealthReportApplicationRepository passwordHealthReportApplicationRepository)
18+
{
19+
_organizationRepo = organizationRepository;
20+
_passwordHealthReportApplicationRepo = passwordHealthReportApplicationRepository;
21+
}
22+
23+
public async Task<PasswordHealthReportApplication> AddPasswordHealthReportApplicationAsync(AddPasswordHealthReportApplicationRequest request)
24+
{
25+
var (req, IsValid, errorMessage) = await ValidateRequestAsync(request);
26+
if (!IsValid)
27+
{
28+
throw new BadRequestException(errorMessage);
29+
}
30+
31+
var passwordHealthReportApplication = new PasswordHealthReportApplication
32+
{
33+
OrganizationId = request.OrganizationId,
34+
Uri = request.Url,
35+
};
36+
37+
passwordHealthReportApplication.SetNewId();
38+
39+
var data = await _passwordHealthReportApplicationRepo.CreateAsync(passwordHealthReportApplication);
40+
return data;
41+
}
42+
43+
public async Task<IEnumerable<PasswordHealthReportApplication>> AddPasswordHealthReportApplicationAsync(IEnumerable<AddPasswordHealthReportApplicationRequest> requests)
44+
{
45+
var requestsList = requests.ToList();
46+
47+
// create tasks to validate each request
48+
var tasks = requestsList.Select(async request =>
49+
{
50+
var (req, IsValid, errorMessage) = await ValidateRequestAsync(request);
51+
if (!IsValid)
52+
{
53+
throw new BadRequestException(errorMessage);
54+
}
55+
});
56+
57+
// run validations and allow exceptions to bubble
58+
await Task.WhenAll(tasks);
59+
60+
// create PasswordHealthReportApplication entities
61+
var passwordHealthReportApplications = requestsList.Select(request =>
62+
{
63+
var pwdHealthReportApplication = new PasswordHealthReportApplication
64+
{
65+
OrganizationId = request.OrganizationId,
66+
Uri = request.Url,
67+
};
68+
pwdHealthReportApplication.SetNewId();
69+
return pwdHealthReportApplication;
70+
});
71+
72+
// create and return the entities
73+
var response = new List<PasswordHealthReportApplication>();
74+
foreach (var record in passwordHealthReportApplications)
75+
{
76+
var data = await _passwordHealthReportApplicationRepo.CreateAsync(record);
77+
response.Add(data);
78+
}
79+
80+
return response;
81+
}
82+
83+
private async Task<Tuple<AddPasswordHealthReportApplicationRequest, bool, string>> ValidateRequestAsync(
84+
AddPasswordHealthReportApplicationRequest request)
85+
{
86+
// verify that the organization exists
87+
var organization = await _organizationRepo.GetByIdAsync(request.OrganizationId);
88+
if (organization == null)
89+
{
90+
return new Tuple<AddPasswordHealthReportApplicationRequest, bool, string>(request, false, "Invalid Organization");
91+
}
92+
93+
// ensure that we have a URL
94+
if (string.IsNullOrWhiteSpace(request.Url))
95+
{
96+
return new Tuple<AddPasswordHealthReportApplicationRequest, bool, string>(request, false, "URL is required");
97+
}
98+
99+
return new Tuple<AddPasswordHealthReportApplicationRequest, bool, string>(request, true, string.Empty);
100+
}
101+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using Bit.Core.Exceptions;
2+
using Bit.Core.Tools.Entities;
3+
using Bit.Core.Tools.ReportFeatures.Interfaces;
4+
using Bit.Core.Tools.Repositories;
5+
6+
namespace Bit.Core.Tools.ReportFeatures;
7+
8+
public class GetPasswordHealthReportApplicationQuery : IGetPasswordHealthReportApplicationQuery
9+
{
10+
private IPasswordHealthReportApplicationRepository _passwordHealthReportApplicationRepo;
11+
12+
public GetPasswordHealthReportApplicationQuery(
13+
IPasswordHealthReportApplicationRepository passwordHealthReportApplicationRepo)
14+
{
15+
_passwordHealthReportApplicationRepo = passwordHealthReportApplicationRepo;
16+
}
17+
18+
public async Task<IEnumerable<PasswordHealthReportApplication>> GetPasswordHealthReportApplicationAsync(Guid organizationId)
19+
{
20+
if (organizationId == Guid.Empty)
21+
{
22+
throw new BadRequestException("OrganizationId is required.");
23+
}
24+
25+
return await _passwordHealthReportApplicationRepo.GetByOrganizationIdAsync(organizationId);
26+
}
27+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
using Bit.Core.Tools.Entities;
2+
using Bit.Core.Tools.Requests;
3+
4+
namespace Bit.Core.Tools.ReportFeatures.Interfaces;
5+
6+
public interface IAddPasswordHealthReportApplicationCommand
7+
{
8+
Task<PasswordHealthReportApplication> AddPasswordHealthReportApplicationAsync(AddPasswordHealthReportApplicationRequest request);
9+
Task<IEnumerable<PasswordHealthReportApplication>> AddPasswordHealthReportApplicationAsync(IEnumerable<AddPasswordHealthReportApplicationRequest> requests);
10+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
using Bit.Core.Tools.Entities;
2+
3+
namespace Bit.Core.Tools.ReportFeatures.Interfaces;
4+
5+
public interface IGetPasswordHealthReportApplicationQuery
6+
{
7+
Task<IEnumerable<PasswordHealthReportApplication>> GetPasswordHealthReportApplicationAsync(Guid organizationId);
8+
}

src/Core/Tools/ReportFeatures/ReportingServiceCollectionExtensions.cs

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-

1+
using Bit.Core.Tools.ReportFeatures.Interfaces;
22
using Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces;
33
using Microsoft.Extensions.DependencyInjection;
44

@@ -9,5 +9,7 @@ public static class ReportingServiceCollectionExtensions
99
public static void AddReportingServices(this IServiceCollection services)
1010
{
1111
services.AddScoped<IMemberAccessCipherDetailsQuery, MemberAccessCipherDetailsQuery>();
12+
services.AddScoped<IAddPasswordHealthReportApplicationCommand, AddPasswordHealthReportApplicationCommand>();
13+
services.AddScoped<IGetPasswordHealthReportApplicationQuery, GetPasswordHealthReportApplicationQuery>();
1214
}
1315
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace Bit.Core.Tools.Requests;
2+
3+
public class AddPasswordHealthReportApplicationRequest
4+
{
5+
public Guid OrganizationId { get; set; }
6+
public string Url { get; set; }
7+
}

src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs

+1
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ public static void AddPasswordManagerEFRepositories(this IServiceCollection serv
9595
services.AddSingleton<INotificationStatusRepository, NotificationStatusRepository>();
9696
services
9797
.AddSingleton<IClientOrganizationMigrationRecordRepository, ClientOrganizationMigrationRecordRepository>();
98+
services.AddSingleton<IPasswordHealthReportApplicationRepository, PasswordHealthReportApplicationRepository>();
9899

99100
if (selfHosted)
100101
{
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
using Bit.Api.Tools.Controllers;
2+
using Bit.Core.Context;
3+
using Bit.Core.Exceptions;
4+
using Bit.Core.Tools.ReportFeatures.Interfaces;
5+
using Bit.Test.Common.AutoFixture;
6+
using Bit.Test.Common.AutoFixture.Attributes;
7+
using NSubstitute;
8+
using Xunit;
9+
10+
namespace Bit.Api.Test.Tools.Controllers;
11+
12+
13+
[ControllerCustomize(typeof(ReportsController))]
14+
[SutProviderCustomize]
15+
public class ReportsControllerTests
16+
{
17+
[Theory, BitAutoData]
18+
public async Task GetPasswordHealthReportApplicationAsync_Success(SutProvider<ReportsController> sutProvider)
19+
{
20+
// Arrange
21+
sutProvider.GetDependency<ICurrentContext>().AccessReports(Arg.Any<Guid>()).Returns(true);
22+
23+
// Act
24+
var orgId = Guid.NewGuid();
25+
var result = await sutProvider.Sut.GetPasswordHealthReportApplications(orgId);
26+
27+
// Assert
28+
_ = sutProvider.GetDependency<IGetPasswordHealthReportApplicationQuery>()
29+
.Received(1)
30+
.GetPasswordHealthReportApplicationAsync(Arg.Is<Guid>(_ => _ == orgId));
31+
}
32+
33+
[Theory, BitAutoData]
34+
public async Task GetPasswordHealthReportApplicationAsync_withoutAccess(SutProvider<ReportsController> sutProvider)
35+
{
36+
// Arrange
37+
sutProvider.GetDependency<ICurrentContext>().AccessReports(Arg.Any<Guid>()).Returns(false);
38+
39+
// Act & Assert
40+
var orgId = Guid.NewGuid();
41+
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.GetPasswordHealthReportApplications(orgId));
42+
43+
// Assert
44+
_ = sutProvider.GetDependency<IGetPasswordHealthReportApplicationQuery>()
45+
.Received(0);
46+
}
47+
48+
49+
}

0 commit comments

Comments
 (0)