diff --git a/src/Api/Dirt/Controllers/OrganizationReportsController.cs b/src/Api/Dirt/Controllers/OrganizationReportsController.cs index fc76efc07107..83b97a3b46a4 100644 --- a/src/Api/Dirt/Controllers/OrganizationReportsController.cs +++ b/src/Api/Dirt/Controllers/OrganizationReportsController.cs @@ -1,9 +1,15 @@ -using Bit.Api.Dirt.Models.Response; +using System.Text.Json; +using Bit.Api.Dirt.Models.Response; +using Bit.Api.Utilities; +using Bit.Core; using Bit.Core.Context; -using Bit.Core.Dirt.Models.Data; using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Reports.Services; +using Bit.Core.Dirt.Repositories; using Bit.Core.Exceptions; +using Bit.Core.Services; +using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -24,6 +30,14 @@ public class OrganizationReportsController : Controller private readonly IUpdateOrganizationReportDataCommand _updateOrganizationReportDataCommand; private readonly IGetOrganizationReportApplicationDataQuery _getOrganizationReportApplicationDataQuery; private readonly IUpdateOrganizationReportApplicationDataCommand _updateOrganizationReportApplicationDataCommand; + private readonly IFeatureService _featureService; + private readonly IApplicationCacheService _applicationCacheService; + private readonly IOrganizationReportStorageService _storageService; + private readonly ICreateOrganizationReportCommand _createReportCommand; + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly IUpdateOrganizationReportV2Command _updateReportV2Command; + private readonly IValidateOrganizationReportFileCommand _validateCommand; + private readonly ILogger _logger; public OrganizationReportsController( ICurrentContext currentContext, @@ -36,8 +50,15 @@ public OrganizationReportsController( IGetOrganizationReportDataQuery getOrganizationReportDataQuery, IUpdateOrganizationReportDataCommand updateOrganizationReportDataCommand, IGetOrganizationReportApplicationDataQuery getOrganizationReportApplicationDataQuery, - IUpdateOrganizationReportApplicationDataCommand updateOrganizationReportApplicationDataCommand - ) + IUpdateOrganizationReportApplicationDataCommand updateOrganizationReportApplicationDataCommand, + IFeatureService featureService, + IApplicationCacheService applicationCacheService, + IOrganizationReportStorageService storageService, + ICreateOrganizationReportCommand createReportCommand, + IOrganizationReportRepository organizationReportRepo, + IUpdateOrganizationReportV2Command updateReportV2Command, + IValidateOrganizationReportFileCommand validateCommand, + ILogger logger) { _currentContext = currentContext; _getOrganizationReportQuery = getOrganizationReportQuery; @@ -50,68 +71,208 @@ IUpdateOrganizationReportApplicationDataCommand updateOrganizationReportApplicat _updateOrganizationReportDataCommand = updateOrganizationReportDataCommand; _getOrganizationReportApplicationDataQuery = getOrganizationReportApplicationDataQuery; _updateOrganizationReportApplicationDataCommand = updateOrganizationReportApplicationDataCommand; + _featureService = featureService; + _applicationCacheService = applicationCacheService; + _storageService = storageService; + _createReportCommand = createReportCommand; + _organizationReportRepo = organizationReportRepo; + _updateReportV2Command = updateReportV2Command; + _validateCommand = validateCommand; + _logger = logger; } - #region Whole OrganizationReport Endpoints [HttpGet("{organizationId}/latest")] public async Task GetLatestOrganizationReportAsync(Guid organizationId) { + if (_featureService.IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2)) + { + await AuthorizeAsync(organizationId); + + var latestReport = await _getOrganizationReportQuery.GetLatestOrganizationReportAsync(organizationId); + if (latestReport == null) + { + return Ok(null); + } + + var response = new OrganizationReportResponseModel(latestReport); + + var fileData = latestReport.GetReportFile(); + if (fileData is { Validated: true }) + { + response.ReportFileDownloadUrl = await _storageService.GetReportDataDownloadUrlAsync(latestReport, fileData); + } + + return Ok(response); + } + if (!await _currentContext.AccessReports(organizationId)) { throw new NotFoundException(); } - var latestReport = await _getOrganizationReportQuery.GetLatestOrganizationReportAsync(organizationId); - var response = latestReport == null ? null : new OrganizationReportResponseModel(latestReport); + var v1LatestReport = await _getOrganizationReportQuery.GetLatestOrganizationReportAsync(organizationId); + var v1Response = v1LatestReport == null ? null : new OrganizationReportResponseModel(v1LatestReport); - return Ok(response); + return Ok(v1Response); } - [HttpGet("{organizationId}/{reportId}")] - public async Task GetOrganizationReportAsync(Guid organizationId, Guid reportId) + /** + * Keeping post v2 launch of Access Intelligence + **/ + + // CREATE Whole Report + [HttpPost("{organizationId}")] + [RequestSizeLimit(Constants.FileSize501mb)] + public async Task CreateOrganizationReportAsync( + Guid organizationId, + [FromBody] AddOrganizationReportRequest request) { - if (!await _currentContext.AccessReports(organizationId)) + if (_featureService.IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2)) { - throw new NotFoundException(); - } + if (organizationId == Guid.Empty) + { + throw new BadRequestException("Organization ID is required."); + } - var report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); + if (request.OrganizationId != organizationId) + { + throw new BadRequestException("Organization ID in the request body must match the route parameter"); + } - if (report == null) + if (!request.FileSize.HasValue) + { + throw new BadRequestException("File size is required."); + } + + if (request.FileSize.Value > Constants.FileSize501mb) + { + throw new BadRequestException("Max file size is 500 MB."); + } + + await AuthorizeAsync(organizationId); + + var report = await _createReportCommand.CreateAsync(request); + var fileData = report.GetReportFile()!; + + return Ok(new OrganizationReportFileResponseModel + { + ReportFileUploadUrl = await _storageService.GetReportFileUploadUrlAsync(report, fileData), + ReportResponse = new OrganizationReportResponseModel(report), + FileUploadType = _storageService.FileUploadType + }); + } + + if (!await _currentContext.AccessReports(organizationId)) { - throw new NotFoundException("Report not found for the specified organization."); + throw new NotFoundException(); } - if (report.OrganizationId != organizationId) + if (request.OrganizationId != organizationId) { - throw new BadRequestException("Invalid report ID"); + throw new BadRequestException("Organization ID in the request body must match the route parameter"); } - return Ok(report); + var v1Report = await _addOrganizationReportCommand.AddOrganizationReportAsync(request); + var response = v1Report == null ? null : new OrganizationReportResponseModel(v1Report); + return Ok(response); } - [HttpPost("{organizationId}")] - public async Task CreateOrganizationReportAsync(Guid organizationId, [FromBody] AddOrganizationReportRequest request) + // READ Whole Report BY IDs + [HttpGet("{organizationId}/{reportId}")] + public async Task GetOrganizationReportAsync(Guid organizationId, Guid reportId) { + if (_featureService.IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2)) + { + await AuthorizeAsync(organizationId); + + var report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); + + if (report == null) + { + throw new NotFoundException("Report not found for the specified organization."); + } + + if (report.OrganizationId != organizationId) + { + throw new BadRequestException("Invalid report ID"); + } + + var response = new OrganizationReportResponseModel(report); + + var fileData = report.GetReportFile(); + if (fileData is { Validated: true }) + { + response.ReportFileDownloadUrl = await _storageService.GetReportDataDownloadUrlAsync(report, fileData); + } + + return Ok(response); + } + if (!await _currentContext.AccessReports(organizationId)) { throw new NotFoundException(); } - if (request.OrganizationId != organizationId) + var v1Report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); + + if (v1Report == null) { - throw new BadRequestException("Organization ID in the request body must match the route parameter"); + throw new NotFoundException("Report not found for the specified organization."); } - var report = await _addOrganizationReportCommand.AddOrganizationReportAsync(request); - var response = report == null ? null : new OrganizationReportResponseModel(report); - return Ok(response); + if (v1Report.OrganizationId != organizationId) + { + throw new BadRequestException("Invalid report ID"); + } + + return Ok(v1Report); } + // UPDATE Whole Report [HttpPatch("{organizationId}/{reportId}")] - public async Task UpdateOrganizationReportAsync(Guid organizationId, [FromBody] UpdateOrganizationReportRequest request) + [RequestSizeLimit(Constants.FileSize501mb)] + public async Task UpdateOrganizationReportAsync( + Guid organizationId, + Guid reportId, + [FromBody] UpdateOrganizationReportV2Request request) { + if (_featureService.IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2)) + { + await AuthorizeAsync(organizationId); + + request.OrganizationId = organizationId; + request.ReportId = reportId; + + if (request.RequiresNewFileUpload) + { + if (!request.FileSize.HasValue) + { + throw new BadRequestException("File size is required."); + } + + if (request.FileSize.Value > Constants.FileSize501mb) + { + throw new BadRequestException("Max file size is 500 MB."); + } + } + + var report = await _updateReportV2Command.UpdateAsync(request); + + if (request.RequiresNewFileUpload) + { + var fileData = report.GetReportFile()!; + return Ok(new OrganizationReportFileResponseModel + { + ReportFileUploadUrl = await _storageService.GetReportFileUploadUrlAsync(report, fileData), + ReportResponse = new OrganizationReportResponseModel(report), + FileUploadType = _storageService.FileUploadType + }); + } + + return Ok(new OrganizationReportResponseModel(report)); + } + if (!await _currentContext.AccessReports(organizationId)) { throw new NotFoundException(); @@ -122,31 +283,32 @@ public async Task UpdateOrganizationReportAsync(Guid organization throw new BadRequestException("Organization ID in the request body must match the route parameter"); } - var updatedReport = await _updateOrganizationReportCommand.UpdateOrganizationReportAsync(request); + var v1Request = new UpdateOrganizationReportRequest + { + ReportId = reportId, + OrganizationId = organizationId, + ReportData = request.ReportData, + ContentEncryptionKey = request.ContentEncryptionKey, + SummaryData = request.SummaryData, + ApplicationData = request.ApplicationData + }; + + var updatedReport = await _updateOrganizationReportCommand.UpdateOrganizationReportAsync(v1Request); var response = new OrganizationReportResponseModel(updatedReport); return Ok(response); } - #endregion - - # region SummaryData Field Endpoints - /// - /// Gets summary data for organization reports within a specified date range. - /// The response is optimized for widget display by returning up to 6 entries that are - /// evenly spaced across the date range, including the most recent entry. + /// Gets summary data for organization reports within a specified date range. + /// The response is optimized for widget display by returning up to 6 entries that are + /// evenly spaced across the date range, including the most recent entry. /// This allows the widget to show trends over time while ensuring the latest data point is always included. /// /// /// /// /// - /// - /// [HttpGet("{organizationId}/data/summary")] - [ProducesResponseType>(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task GetOrganizationReportSummaryDataByDateRangeAsync( Guid organizationId, [FromQuery] DateTime startDate, [FromQuery] DateTime endDate) { @@ -166,27 +328,183 @@ public async Task GetOrganizationReportSummaryDataByDateRangeAsyn return Ok(summaryDataList); } - [HttpGet("{organizationId}/data/summary/{reportId}")] - public async Task GetOrganizationReportSummaryAsync(Guid organizationId, Guid reportId) + [RequireFeature(FeatureFlagKeys.AccessIntelligenceVersion2)] + [HttpPost("{organizationId}/{reportId}/file/report-data")] + [SelfHosted(SelfHostedOnly = true)] + [RequestSizeLimit(Constants.FileSize501mb)] + [DisableFormValueModelBinding] + public async Task UploadReportFileAsync(Guid organizationId, Guid reportId, [FromQuery] string reportFileId) + { + await AuthorizeAsync(organizationId); + + if (!Request?.ContentType?.Contains("multipart/") ?? true) + { + throw new BadRequestException("Invalid content."); + } + + if (string.IsNullOrEmpty(reportFileId)) + { + throw new BadRequestException("ReportFileId query parameter is required"); + } + + var report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); + if (report == null) + { + throw new NotFoundException(); + } + + if (report.OrganizationId != organizationId) + { + throw new BadRequestException("Invalid report ID"); + } + + var fileData = report.GetReportFile(); + if (fileData == null || fileData.Id != reportFileId) + { + throw new NotFoundException(); + } + + await Request.GetFileAsync(async (stream) => + { + await _storageService.UploadReportDataAsync(report, fileData, stream); + }); + + var leeway = 1024L * 1024L; // 1 MB + var minimum = Math.Max(0, fileData.Size - leeway); + var maximum = Math.Min(fileData.Size + leeway, Constants.FileSize501mb); + var (valid, length) = await _storageService.ValidateFileAsync(report, fileData, minimum, maximum); + if (!valid) + { + throw new BadRequestException("File received does not match expected constraints."); + } + + fileData.Validated = true; + fileData.Size = length; + report.SetReportFile(fileData); + report.RevisionDate = DateTime.UtcNow; + await _organizationReportRepo.ReplaceAsync(report); + } + + [AllowAnonymous] + [RequireFeature(FeatureFlagKeys.AccessIntelligenceVersion2)] + [HttpPost("file/validate/azure")] + public async Task AzureValidateFile() + { + return await ApiHelpers.HandleAzureEvents(Request, new Dictionary> + { + { + "Microsoft.Storage.BlobCreated", async (eventGridEvent) => + { + try + { + var blobName = + eventGridEvent.Subject.Split($"{AzureOrganizationReportStorageService.ContainerName}/blobs/")[1]; + var reportId = AzureOrganizationReportStorageService.ReportIdFromBlobName(blobName); + var report = await _organizationReportRepo.GetByIdAsync(new Guid(reportId)); + if (report == null) + { + if (_storageService is AzureOrganizationReportStorageService azureStorageService) + { + await azureStorageService.DeleteBlobAsync(blobName); + } + + return; + } + + var fileData = report.GetReportFile(); + if (fileData == null) + { + return; + } + + await _validateCommand.ValidateAsync(report, fileData.Id!); + } + catch (Exception e) + { + _logger.LogError(e, "Uncaught exception occurred while handling event grid event: {Event}", + JsonSerializer.Serialize(eventGridEvent)); + } + } + } + }); + } + + private async Task AuthorizeAsync(Guid organizationId) { if (!await _currentContext.AccessReports(organizationId)) { throw new NotFoundException(); } - var summaryData = - await _getOrganizationReportSummaryDataQuery.GetOrganizationReportSummaryDataAsync(organizationId, reportId); + var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId); + if (orgAbility is null || !orgAbility.UseRiskInsights) + { + throw new BadRequestException("Your organization's plan does not support this feature."); + } + } - if (summaryData == null) + // Removing post v2 launch + [HttpPatch("{organizationId}/data/application/{reportId}")] + public async Task UpdateOrganizationReportApplicationDataAsync(Guid organizationId, Guid reportId, [FromBody] UpdateOrganizationReportApplicationDataRequest request) + { + try { - throw new NotFoundException("Report not found for the specified organization."); + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + if (request.OrganizationId != organizationId) + { + throw new BadRequestException("Organization ID in the request body must match the route parameter"); + } + + if (request.Id != reportId) + { + throw new BadRequestException("Report ID in the request body must match the route parameter"); + } + + var updatedReport = await _updateOrganizationReportApplicationDataCommand.UpdateOrganizationReportApplicationDataAsync(request); + var response = new OrganizationReportResponseModel(updatedReport); + + return Ok(response); + } + catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + { + throw; } + } - return Ok(summaryData); + [HttpGet("{organizationId}/data/application/{reportId}")] + public async Task GetOrganizationReportApplicationDataAsync(Guid organizationId, Guid reportId) + { + try + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + var applicationData = await _getOrganizationReportApplicationDataQuery.GetOrganizationReportApplicationDataAsync(organizationId, reportId); + + if (applicationData == null) + { + throw new NotFoundException("Organization report application data not found."); + } + + return Ok(applicationData); + } + catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + { + throw; + } } - [HttpPatch("{organizationId}/data/summary/{reportId}")] - public async Task UpdateOrganizationReportSummaryAsync(Guid organizationId, Guid reportId, [FromBody] UpdateOrganizationReportSummaryRequest request) + [HttpPatch("{organizationId}/data/report/{reportId}")] + public async Task UpdateOrganizationReportDataAsync( + Guid organizationId, + Guid reportId, + [FromBody] UpdateOrganizationReportDataRequest request) { if (!await _currentContext.AccessReports(organizationId)) { @@ -202,14 +520,12 @@ public async Task UpdateOrganizationReportSummaryAsync(Guid organ { throw new BadRequestException("Report ID in the request body must match the route parameter"); } - var updatedReport = await _updateOrganizationReportSummaryCommand.UpdateOrganizationReportSummaryAsync(request); + + var updatedReport = await _updateOrganizationReportDataCommand.UpdateOrganizationReportDataAsync(request); var response = new OrganizationReportResponseModel(updatedReport); return Ok(response); } - #endregion - - #region ReportData Field Endpoints [HttpGet("{organizationId}/data/report/{reportId}")] public async Task GetOrganizationReportDataAsync(Guid organizationId, Guid reportId) @@ -229,8 +545,8 @@ public async Task GetOrganizationReportDataAsync(Guid organizatio return Ok(reportData); } - [HttpPatch("{organizationId}/data/report/{reportId}")] - public async Task UpdateOrganizationReportDataAsync(Guid organizationId, Guid reportId, [FromBody] UpdateOrganizationReportDataRequest request) + [HttpPatch("{organizationId}/data/summary/{reportId}")] + public async Task UpdateOrganizationReportSummaryAsync(Guid organizationId, Guid reportId, [FromBody] UpdateOrganizationReportSummaryRequest request) { if (!await _currentContext.AccessReports(organizationId)) { @@ -246,72 +562,28 @@ public async Task UpdateOrganizationReportDataAsync(Guid organiza { throw new BadRequestException("Report ID in the request body must match the route parameter"); } - - var updatedReport = await _updateOrganizationReportDataCommand.UpdateOrganizationReportDataAsync(request); + var updatedReport = await _updateOrganizationReportSummaryCommand.UpdateOrganizationReportSummaryAsync(request); var response = new OrganizationReportResponseModel(updatedReport); return Ok(response); } - #endregion - - #region ApplicationData Field Endpoints - - [HttpGet("{organizationId}/data/application/{reportId}")] - public async Task GetOrganizationReportApplicationDataAsync(Guid organizationId, Guid reportId) + [HttpGet("{organizationId}/data/summary/{reportId}")] + public async Task GetOrganizationReportSummaryAsync(Guid organizationId, Guid reportId) { - try - { - if (!await _currentContext.AccessReports(organizationId)) - { - throw new NotFoundException(); - } - - var applicationData = await _getOrganizationReportApplicationDataQuery.GetOrganizationReportApplicationDataAsync(organizationId, reportId); - - if (applicationData == null) - { - throw new NotFoundException("Organization report application data not found."); - } - - return Ok(applicationData); - } - catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + if (!await _currentContext.AccessReports(organizationId)) { - throw; + throw new NotFoundException(); } - } - - [HttpPatch("{organizationId}/data/application/{reportId}")] - public async Task UpdateOrganizationReportApplicationDataAsync(Guid organizationId, Guid reportId, [FromBody] UpdateOrganizationReportApplicationDataRequest request) - { - try - { - if (!await _currentContext.AccessReports(organizationId)) - { - throw new NotFoundException(); - } - - if (request.OrganizationId != organizationId) - { - throw new BadRequestException("Organization ID in the request body must match the route parameter"); - } - - if (request.Id != reportId) - { - throw new BadRequestException("Report ID in the request body must match the route parameter"); - } - var updatedReport = await _updateOrganizationReportApplicationDataCommand.UpdateOrganizationReportApplicationDataAsync(request); - var response = new OrganizationReportResponseModel(updatedReport); + var summaryData = + await _getOrganizationReportSummaryDataQuery.GetOrganizationReportSummaryDataAsync(organizationId, reportId); - return Ok(response); - } - catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + if (summaryData == null) { - throw; + throw new NotFoundException("Report not found for the specified organization."); } - } - #endregion + return Ok(summaryData); + } } diff --git a/src/Api/Dirt/Models/Response/OrganizationReportFileResponseModel.cs b/src/Api/Dirt/Models/Response/OrganizationReportFileResponseModel.cs new file mode 100644 index 000000000000..c6ac4607ebfe --- /dev/null +++ b/src/Api/Dirt/Models/Response/OrganizationReportFileResponseModel.cs @@ -0,0 +1,12 @@ +using Bit.Core.Enums; + +namespace Bit.Api.Dirt.Models.Response; + +public class OrganizationReportFileResponseModel +{ + public OrganizationReportFileResponseModel() { } + + public string ReportFileUploadUrl { get; set; } = string.Empty; + public OrganizationReportResponseModel ReportResponse { get; set; } = null!; + public FileUploadType FileUploadType { get; set; } +} diff --git a/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs b/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs index e477e5b806a7..f0f3a90c1102 100644 --- a/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs +++ b/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs @@ -1,4 +1,5 @@ using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Models.Data; namespace Bit.Api.Dirt.Models.Response; @@ -10,11 +11,10 @@ public class OrganizationReportResponseModel public string? ContentEncryptionKey { get; set; } public string? SummaryData { get; set; } public string? ApplicationData { get; set; } - public int? PasswordCount { get; set; } - public int? PasswordAtRiskCount { get; set; } - public int? MemberCount { get; set; } - public DateTime? CreationDate { get; set; } = null; - public DateTime? RevisionDate { get; set; } = null; + public ReportFile? ReportFile { get; set; } + public string? ReportFileDownloadUrl { get; set; } + public DateTime? CreationDate { get; set; } + public DateTime? RevisionDate { get; set; } public OrganizationReportResponseModel(OrganizationReport organizationReport) { @@ -29,9 +29,7 @@ public OrganizationReportResponseModel(OrganizationReport organizationReport) ContentEncryptionKey = organizationReport.ContentEncryptionKey; SummaryData = organizationReport.SummaryData; ApplicationData = organizationReport.ApplicationData; - PasswordCount = organizationReport.PasswordCount; - PasswordAtRiskCount = organizationReport.PasswordAtRiskCount; - MemberCount = organizationReport.MemberCount; + ReportFile = organizationReport.GetReportFile(); CreationDate = organizationReport.CreationDate; RevisionDate = organizationReport.RevisionDate; } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 9f360127b0fe..e69815a43126 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -267,6 +267,7 @@ public static class FeatureFlagKeys public const string ArchiveVaultItems = "pm-19148-innovation-archive"; /* DIRT Team */ + public const string AccessIntelligenceVersion2 = "pm-31920-access-intelligence-azure-file-storage"; public const string EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike"; public const string EventDiagnosticLogging = "pm-27666-siem-event-log-debugging"; public const string EventManagementForHuntress = "event-management-for-huntress"; diff --git a/src/Core/Dirt/Entities/OrganizationReport.cs b/src/Core/Dirt/Entities/OrganizationReport.cs index 409672f15a98..098573dc34f3 100644 --- a/src/Core/Dirt/Entities/OrganizationReport.cs +++ b/src/Core/Dirt/Entities/OrganizationReport.cs @@ -1,5 +1,5 @@ -#nullable enable - +using System.Text.Json; +using Bit.Core.Dirt.Models.Data; using Bit.Core.Entities; using Bit.Core.Utilities; @@ -29,6 +29,21 @@ public class OrganizationReport : ITableObject public int? CriticalPasswordAtRiskCount { get; set; } public string? ReportFile { get; set; } + public ReportFile? GetReportFile() + { + if (string.IsNullOrWhiteSpace(ReportFile)) + { + return null; + } + + return JsonSerializer.Deserialize(ReportFile); + } + + public void SetReportFile(ReportFile data) + { + ReportFile = JsonSerializer.Serialize(data, JsonHelpers.IgnoreWritingNull); + } + public void SetNewId() { Id = CoreHelpers.GenerateComb(); diff --git a/src/Core/Dirt/Models/Data/OrganizationReportMetricsData.cs b/src/Core/Dirt/Models/Data/OrganizationReportMetricsData.cs index ffef91275a64..957dca5d641e 100644 --- a/src/Core/Dirt/Models/Data/OrganizationReportMetricsData.cs +++ b/src/Core/Dirt/Models/Data/OrganizationReportMetricsData.cs @@ -18,7 +18,7 @@ public class OrganizationReportMetricsData public int? CriticalPasswordCount { get; set; } public int? CriticalPasswordAtRiskCount { get; set; } - public static OrganizationReportMetricsData From(Guid organizationId, OrganizationReportMetricsRequest? request) + public static OrganizationReportMetricsData From(Guid organizationId, OrganizationReportMetrics? request) { if (request == null) { diff --git a/src/Core/Dirt/Models/Data/ReportFile.cs b/src/Core/Dirt/Models/Data/ReportFile.cs index db57079dc549..fa0cb11166e9 100644 --- a/src/Core/Dirt/Models/Data/ReportFile.cs +++ b/src/Core/Dirt/Models/Data/ReportFile.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; using static System.Text.Json.Serialization.JsonNumberHandling; @@ -28,5 +26,5 @@ public class ReportFile /// /// When true the uploaded file's length has been validated. /// - public bool Validated { get; set; } = true; + public bool Validated { get; set; } = false; } diff --git a/src/Core/Dirt/Reports/ReportFeatures/AddOrganizationReportCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/AddOrganizationReportCommand.cs index 7c2dc66f604c..df17a72844b0 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/AddOrganizationReportCommand.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/AddOrganizationReportCommand.cs @@ -41,7 +41,7 @@ public async Task AddOrganizationReportAsync(AddOrganization throw new BadRequestException(errorMessage); } - var requestMetrics = request.Metrics ?? new OrganizationReportMetricsRequest(); + var requestMetrics = request.ReportMetrics ?? new OrganizationReportMetrics(); var organizationReport = new OrganizationReport { diff --git a/src/Core/Dirt/Reports/ReportFeatures/CreateOrganizationReportCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/CreateOrganizationReportCommand.cs new file mode 100644 index 000000000000..3eeac7518c02 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/CreateOrganizationReportCommand.cs @@ -0,0 +1,99 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class CreateOrganizationReportCommand : ICreateOrganizationReportCommand +{ + private readonly IOrganizationRepository _organizationRepo; + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly ILogger _logger; + + public CreateOrganizationReportCommand( + IOrganizationRepository organizationRepository, + IOrganizationReportRepository organizationReportRepository, + ILogger logger) + { + _organizationRepo = organizationRepository; + _organizationReportRepo = organizationReportRepository; + _logger = logger; + } + + public async Task CreateAsync(AddOrganizationReportRequest request) + { + _logger.LogInformation(Constants.BypassFiltersEventId, + "Creating organization report for organization {organizationId}", request.OrganizationId); + + var (isValid, errorMessage) = await ValidateRequestAsync(request); + if (!isValid) + { + _logger.LogInformation(Constants.BypassFiltersEventId, + "Failed to create organization {organizationId} report: {errorMessage}", + request.OrganizationId, errorMessage); + throw new BadRequestException(errorMessage); + } + + var fileData = new ReportFile + { + Id = CoreHelpers.SecureRandomString(32, upper: false, special: false), + FileName = "report-data.json", + Size = request.FileSize ?? 0, + Validated = false + }; + + var organizationReport = new OrganizationReport + { + OrganizationId = request.OrganizationId, + CreationDate = DateTime.UtcNow, + ContentEncryptionKey = request.ContentEncryptionKey ?? string.Empty, + SummaryData = request.SummaryData, + ApplicationData = request.ApplicationData, + ApplicationCount = request.ReportMetrics?.ApplicationCount, + ApplicationAtRiskCount = request.ReportMetrics?.ApplicationAtRiskCount, + CriticalApplicationCount = request.ReportMetrics?.CriticalApplicationCount, + CriticalApplicationAtRiskCount = request.ReportMetrics?.CriticalApplicationAtRiskCount, + MemberCount = request.ReportMetrics?.MemberCount, + MemberAtRiskCount = request.ReportMetrics?.MemberAtRiskCount, + CriticalMemberCount = request.ReportMetrics?.CriticalMemberCount, + CriticalMemberAtRiskCount = request.ReportMetrics?.CriticalMemberAtRiskCount, + PasswordCount = request.ReportMetrics?.PasswordCount, + PasswordAtRiskCount = request.ReportMetrics?.PasswordAtRiskCount, + CriticalPasswordCount = request.ReportMetrics?.CriticalPasswordCount, + CriticalPasswordAtRiskCount = request.ReportMetrics?.CriticalPasswordAtRiskCount, + RevisionDate = DateTime.UtcNow + }; + organizationReport.SetReportFile(fileData); + + var data = await _organizationReportRepo.CreateAsync(organizationReport); + + _logger.LogInformation(Constants.BypassFiltersEventId, + "Successfully created organization report for organization {organizationId}, {organizationReportId}", + request.OrganizationId, data.Id); + + return data; + } + + private async Task<(bool IsValid, string errorMessage)> ValidateRequestAsync( + AddOrganizationReportRequest request) + { + var organization = await _organizationRepo.GetByIdAsync(request.OrganizationId); + if (organization == null) + { + return (false, "Invalid Organization"); + } + + if (string.IsNullOrWhiteSpace(request.ContentEncryptionKey)) + { + return (false, "Content Encryption Key is required"); + } + + return (true, string.Empty); + } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportApplicationDataQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportApplicationDataQuery.cs index 983fa71fd781..f2947b847d9a 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportApplicationDataQuery.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportApplicationDataQuery.cs @@ -21,42 +21,23 @@ public GetOrganizationReportApplicationDataQuery( public async Task GetOrganizationReportApplicationDataAsync(Guid organizationId, Guid reportId) { - try + if (organizationId == Guid.Empty) { - _logger.LogInformation(Constants.BypassFiltersEventId, "Fetching organization report application data for organization {organizationId} and report {reportId}", - organizationId, reportId); - - if (organizationId == Guid.Empty) - { - _logger.LogWarning(Constants.BypassFiltersEventId, "GetOrganizationReportApplicationDataAsync called with empty OrganizationId"); - throw new BadRequestException("OrganizationId is required."); - } - - if (reportId == Guid.Empty) - { - _logger.LogWarning(Constants.BypassFiltersEventId, "GetOrganizationReportApplicationDataAsync called with empty ReportId"); - throw new BadRequestException("ReportId is required."); - } - - var applicationDataResponse = await _organizationReportRepo.GetApplicationDataAsync(reportId); - - if (applicationDataResponse == null) - { - _logger.LogWarning(Constants.BypassFiltersEventId, "No application data found for organization {organizationId} and report {reportId}", - organizationId, reportId); - throw new NotFoundException("Organization report application data not found."); - } - - _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully retrieved organization report application data for organization {organizationId} and report {reportId}", - organizationId, reportId); - - return applicationDataResponse; + throw new BadRequestException("OrganizationId is required."); } - catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + + if (reportId == Guid.Empty) + { + throw new BadRequestException("ReportId is required."); + } + + var applicationDataResponse = await _organizationReportRepo.GetApplicationDataAsync(reportId); + + if (applicationDataResponse == null) { - _logger.LogError(ex, "Error fetching organization report application data for organization {organizationId} and report {reportId}", - organizationId, reportId); - throw; + throw new NotFoundException("Organization report application data not found."); } + + return applicationDataResponse; } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/ICreateOrganizationReportCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/ICreateOrganizationReportCommand.cs new file mode 100644 index 000000000000..b090dd12d609 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/ICreateOrganizationReportCommand.cs @@ -0,0 +1,9 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface ICreateOrganizationReportCommand +{ + Task CreateAsync(AddOrganizationReportRequest request); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportV2Command.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportV2Command.cs new file mode 100644 index 000000000000..a67c7c725d5f --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportV2Command.cs @@ -0,0 +1,9 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface IUpdateOrganizationReportV2Command +{ + Task UpdateAsync(UpdateOrganizationReportV2Request request); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IValidateOrganizationReportFileCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IValidateOrganizationReportFileCommand.cs new file mode 100644 index 000000000000..bba11a205966 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IValidateOrganizationReportFileCommand.cs @@ -0,0 +1,8 @@ +using Bit.Core.Dirt.Entities; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface IValidateOrganizationReportFileCommand +{ + Task ValidateAsync(OrganizationReport report, string reportFileId); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs b/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs index fbbc6967395a..4e1bc0a84beb 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs @@ -27,5 +27,10 @@ public static void AddReportingServices(this IServiceCollection services, IGloba services.AddScoped(); services.AddScoped(); services.AddScoped(); + + // v2 file storage commands + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/AddOrganizationReportRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/AddOrganizationReportRequest.cs index eecc84c522ed..3335ce6cd845 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/Requests/AddOrganizationReportRequest.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/AddOrganizationReportRequest.cs @@ -11,5 +11,10 @@ public class AddOrganizationReportRequest public string? ApplicationData { get; set; } - public OrganizationReportMetricsRequest? Metrics { get; set; } + public OrganizationReportMetrics? ReportMetrics { get; set; } + + /// + /// Estimated size of the report file in bytes. Required for v2 reports. + /// + public long? FileSize { get; set; } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/OrganizationReportMetrics.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/OrganizationReportMetrics.cs new file mode 100644 index 000000000000..e01408a3d532 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/OrganizationReportMetrics.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +public class OrganizationReportMetrics +{ + [JsonPropertyName("totalApplicationCount")] + public int? ApplicationCount { get; set; } = null; + [JsonPropertyName("totalAtRiskApplicationCount")] + public int? ApplicationAtRiskCount { get; set; } = null; + [JsonPropertyName("totalCriticalApplicationCount")] + public int? CriticalApplicationCount { get; set; } = null; + [JsonPropertyName("totalCriticalAtRiskApplicationCount")] + public int? CriticalApplicationAtRiskCount { get; set; } = null; + [JsonPropertyName("totalMemberCount")] + public int? MemberCount { get; set; } = null; + [JsonPropertyName("totalAtRiskMemberCount")] + public int? MemberAtRiskCount { get; set; } = null; + [JsonPropertyName("totalCriticalMemberCount")] + public int? CriticalMemberCount { get; set; } = null; + [JsonPropertyName("totalCriticalAtRiskMemberCount")] + public int? CriticalMemberAtRiskCount { get; set; } = null; + [JsonPropertyName("totalPasswordCount")] + public int? PasswordCount { get; set; } = null; + [JsonPropertyName("totalAtRiskPasswordCount")] + public int? PasswordAtRiskCount { get; set; } = null; + [JsonPropertyName("totalCriticalPasswordCount")] + public int? CriticalPasswordCount { get; set; } = null; + [JsonPropertyName("totalCriticalAtRiskPasswordCount")] + public int? CriticalPasswordAtRiskCount { get; set; } = null; +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportDataRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportDataRequest.cs index 673a3f2ab8e5..4489c4baedf5 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportDataRequest.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportDataRequest.cs @@ -1,11 +1,8 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; public class UpdateOrganizationReportDataRequest { public Guid OrganizationId { get; set; } public Guid ReportId { get; set; } - public string ReportData { get; set; } + public string? ReportData { get; set; } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportSummaryRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportSummaryRequest.cs index 27358537c280..1a63297663ee 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportSummaryRequest.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportSummaryRequest.cs @@ -5,5 +5,5 @@ public class UpdateOrganizationReportSummaryRequest public Guid OrganizationId { get; set; } public Guid ReportId { get; set; } public string? SummaryData { get; set; } - public OrganizationReportMetricsRequest? Metrics { get; set; } + public OrganizationReportMetrics? ReportMetrics { get; set; } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportV2Request.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportV2Request.cs new file mode 100644 index 000000000000..5a44f4684e05 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportV2Request.cs @@ -0,0 +1,18 @@ +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +public class UpdateOrganizationReportV2Request +{ + public Guid ReportId { get; set; } + public Guid OrganizationId { get; set; } + public string? ReportData { get; set; } + public string? ContentEncryptionKey { get; set; } + public string? SummaryData { get; set; } + public string? ApplicationData { get; set; } + public OrganizationReportMetrics? ReportMetrics { get; set; } + public bool RequiresNewFileUpload { get; set; } + + /// + /// Estimated size of the report file in bytes. Required when RequiresNewFileUpload is true. + /// + public long? FileSize { get; set; } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataCommand.cs index f81d24c3d74a..c62cb42058e6 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataCommand.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataCommand.cs @@ -53,7 +53,7 @@ public async Task UpdateOrganizationReportDataAsync(UpdateOr throw new BadRequestException("Organization report does not belong to the specified organization"); } - var updatedReport = await _organizationReportRepo.UpdateReportDataAsync(request.OrganizationId, request.ReportId, request.ReportData); + var updatedReport = await _organizationReportRepo.UpdateReportDataAsync(request.OrganizationId, request.ReportId, request.ReportData ?? string.Empty); _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully updated organization report data {reportId} for organization {organizationId}", request.ReportId, request.OrganizationId); diff --git a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportSummaryCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportSummaryCommand.cs index a0e6c56a0fbc..55f5cbb5a2ef 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportSummaryCommand.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportSummaryCommand.cs @@ -59,7 +59,7 @@ public async Task UpdateOrganizationReportSummaryAsync(Updat throw new BadRequestException("Organization report does not belong to the specified organization"); } - await _organizationReportRepo.UpdateMetricsAsync(request.ReportId, OrganizationReportMetricsData.From(request.OrganizationId, request.Metrics)); + await _organizationReportRepo.UpdateMetricsAsync(request.ReportId, OrganizationReportMetricsData.From(request.OrganizationId, request.ReportMetrics)); var updatedReport = await _organizationReportRepo.UpdateSummaryDataAsync(request.OrganizationId, request.ReportId, request.SummaryData ?? string.Empty); // Invalidate cache diff --git a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportV2Command.cs b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportV2Command.cs new file mode 100644 index 000000000000..468b0a6f7ec6 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportV2Command.cs @@ -0,0 +1,144 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Reports.Services; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class UpdateOrganizationReportV2Command : IUpdateOrganizationReportV2Command +{ + private readonly IOrganizationRepository _organizationRepo; + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly IOrganizationReportStorageService _storageService; + private readonly ILogger _logger; + + public UpdateOrganizationReportV2Command( + IOrganizationRepository organizationRepository, + IOrganizationReportRepository organizationReportRepository, + IOrganizationReportStorageService storageService, + ILogger logger) + { + _organizationRepo = organizationRepository; + _organizationReportRepo = organizationReportRepository; + _storageService = storageService; + _logger = logger; + } + + public async Task UpdateAsync(UpdateOrganizationReportV2Request request) + { + _logger.LogInformation(Constants.BypassFiltersEventId, + "Updating v2 organization report {reportId} for organization {organizationId}", + request.ReportId, request.OrganizationId); + + var (isValid, errorMessage) = await ValidateRequestAsync(request); + if (!isValid) + { + _logger.LogWarning(Constants.BypassFiltersEventId, + "Failed to update v2 organization report {reportId} for organization {organizationId}: {errorMessage}", + request.ReportId, request.OrganizationId, errorMessage); + throw new BadRequestException(errorMessage); + } + + var existingReport = await _organizationReportRepo.GetByIdAsync(request.ReportId); + if (existingReport == null) + { + _logger.LogWarning(Constants.BypassFiltersEventId, + "Organization report {reportId} not found", request.ReportId); + throw new NotFoundException("Organization report not found"); + } + + if (existingReport.OrganizationId != request.OrganizationId) + { + _logger.LogWarning(Constants.BypassFiltersEventId, + "Organization report {reportId} does not belong to organization {organizationId}", + request.ReportId, request.OrganizationId); + throw new BadRequestException("Organization report does not belong to the specified organization"); + } + + if (request.ContentEncryptionKey != null) + { + existingReport.ContentEncryptionKey = request.ContentEncryptionKey; + } + + if (request.SummaryData != null) + { + existingReport.SummaryData = request.SummaryData; + } + + if (request.ApplicationData != null) + { + existingReport.ApplicationData = request.ApplicationData; + } + + if (request.ReportMetrics != null) + { + existingReport.ApplicationCount = request.ReportMetrics.ApplicationCount; + existingReport.ApplicationAtRiskCount = request.ReportMetrics.ApplicationAtRiskCount; + existingReport.CriticalApplicationCount = request.ReportMetrics.CriticalApplicationCount; + existingReport.CriticalApplicationAtRiskCount = request.ReportMetrics.CriticalApplicationAtRiskCount; + existingReport.MemberCount = request.ReportMetrics.MemberCount; + existingReport.MemberAtRiskCount = request.ReportMetrics.MemberAtRiskCount; + existingReport.CriticalMemberCount = request.ReportMetrics.CriticalMemberCount; + existingReport.CriticalMemberAtRiskCount = request.ReportMetrics.CriticalMemberAtRiskCount; + existingReport.PasswordCount = request.ReportMetrics.PasswordCount; + existingReport.PasswordAtRiskCount = request.ReportMetrics.PasswordAtRiskCount; + existingReport.CriticalPasswordCount = request.ReportMetrics.CriticalPasswordCount; + existingReport.CriticalPasswordAtRiskCount = request.ReportMetrics.CriticalPasswordAtRiskCount; + } + + if (request.RequiresNewFileUpload) + { + var oldFileData = existingReport.GetReportFile(); + if (oldFileData?.Id != null) + { + await _storageService.DeleteReportFilesAsync(existingReport, oldFileData.Id); + } + + var fileData = new ReportFile + { + Id = CoreHelpers.SecureRandomString(32, upper: false, special: false), + FileName = "report-data.json", + Validated = false, + Size = request.FileSize ?? 0 + }; + existingReport.SetReportFile(fileData); + } + + existingReport.RevisionDate = DateTime.UtcNow; + await _organizationReportRepo.ReplaceAsync(existingReport); + + _logger.LogInformation(Constants.BypassFiltersEventId, + "Successfully updated v2 organization report {reportId} for organization {organizationId}", + request.ReportId, request.OrganizationId); + + return existingReport; + } + + private async Task<(bool IsValid, string errorMessage)> ValidateRequestAsync( + UpdateOrganizationReportV2Request request) + { + if (request.OrganizationId == Guid.Empty) + { + return (false, "OrganizationId is required"); + } + + if (request.ReportId == Guid.Empty) + { + return (false, "ReportId is required"); + } + + var organization = await _organizationRepo.GetByIdAsync(request.OrganizationId); + if (organization == null) + { + return (false, "Invalid Organization"); + } + + return (true, string.Empty); + } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/ValidateOrganizationReportFileCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/ValidateOrganizationReportFileCommand.cs new file mode 100644 index 000000000000..afb4d0f976d0 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/ValidateOrganizationReportFileCommand.cs @@ -0,0 +1,51 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.Services; +using Bit.Core.Dirt.Repositories; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class ValidateOrganizationReportFileCommand : IValidateOrganizationReportFileCommand +{ + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly IOrganizationReportStorageService _storageService; + private readonly ILogger _logger; + + public ValidateOrganizationReportFileCommand( + IOrganizationReportRepository organizationReportRepo, + IOrganizationReportStorageService storageService, + ILogger logger) + { + _organizationReportRepo = organizationReportRepo; + _storageService = storageService; + _logger = logger; + } + + public async Task ValidateAsync(OrganizationReport report, string reportFileId) + { + var fileData = report.GetReportFile(); + if (fileData == null || fileData.Id != reportFileId) + { + return false; + } + + var (valid, length) = await _storageService.ValidateFileAsync(report, fileData, 0, Constants.FileSize501mb); + if (!valid) + { + _logger.LogWarning( + "Deleted report {ReportId} because its file size {Size} was invalid.", + report.Id, length); + await _storageService.DeleteReportFilesAsync(report, reportFileId); + await _organizationReportRepo.DeleteAsync(report); + return false; + } + + fileData.Validated = true; + fileData.Size = length; + report.SetReportFile(fileData); + report.RevisionDate = DateTime.UtcNow; + await _organizationReportRepo.ReplaceAsync(report); + return true; + } +} diff --git a/src/Core/Dirt/Reports/Services/AzureOrganizationReportStorageService.cs b/src/Core/Dirt/Reports/Services/AzureOrganizationReportStorageService.cs new file mode 100644 index 000000000000..13823cf25765 --- /dev/null +++ b/src/Core/Dirt/Reports/Services/AzureOrganizationReportStorageService.cs @@ -0,0 +1,134 @@ +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using Azure.Storage.Sas; +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Models.Data; +using Bit.Core.Enums; +using Bit.Core.Settings; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.Services; + +public class AzureOrganizationReportStorageService : IOrganizationReportStorageService +{ + public const string ContainerName = "organization-reports"; + private static readonly TimeSpan _sasTokenLifetime = TimeSpan.FromMinutes(1); + + private readonly BlobServiceClient _blobServiceClient; + private readonly ILogger _logger; + private BlobContainerClient? _containerClient; + + public FileUploadType FileUploadType => FileUploadType.Azure; + + public static string ReportIdFromBlobName(string blobName) => blobName.Split('/')[2]; + + public AzureOrganizationReportStorageService( + GlobalSettings globalSettings, + ILogger logger) + { + _blobServiceClient = new BlobServiceClient(globalSettings.OrganizationReport.ConnectionString); + _logger = logger; + } + + /// + /// Constructor for unit testing that accepts a pre-initialized container client, + /// bypassing the network call to Azure Storage. + /// + internal AzureOrganizationReportStorageService( + BlobContainerClient containerClient, + ILogger logger) + { + _blobServiceClient = null!; + _containerClient = containerClient; + _logger = logger; + } + + public async Task GetReportFileUploadUrlAsync(OrganizationReport report, ReportFile fileData) + { + await InitAsync(); + var blobClient = _containerClient!.GetBlobClient(BlobPath(report, fileData.Id!, fileData.FileName)); + return blobClient.GenerateSasUri( + BlobSasPermissions.Create | BlobSasPermissions.Write, + DateTime.UtcNow.Add(_sasTokenLifetime)).ToString(); + } + + public async Task GetReportDataDownloadUrlAsync(OrganizationReport report, ReportFile fileData) + { + await InitAsync(); + var blobClient = _containerClient!.GetBlobClient(BlobPath(report, fileData.Id!, fileData.FileName)); + return blobClient.GenerateSasUri(BlobSasPermissions.Read, + DateTime.UtcNow.Add(_sasTokenLifetime)).ToString(); + } + + public async Task UploadReportDataAsync(OrganizationReport report, ReportFile fileData, Stream stream) + { + await InitAsync(); + var blobClient = _containerClient!.GetBlobClient(BlobPath(report, fileData.Id!, fileData.FileName)); + await blobClient.UploadAsync(stream, overwrite: true); + } + + public async Task<(bool valid, long length)> ValidateFileAsync( + OrganizationReport report, ReportFile fileData, long minimum, long maximum) + { + await InitAsync(); + + var blobClient = _containerClient!.GetBlobClient(BlobPath(report, fileData.Id!, fileData.FileName)); + + try + { + var blobProperties = await blobClient.GetPropertiesAsync(); + var metadata = blobProperties.Value.Metadata; + metadata["organizationId"] = report.OrganizationId.ToString(); + await blobClient.SetMetadataAsync(metadata); + + var headers = new BlobHttpHeaders + { + ContentDisposition = $"attachment; filename=\"{fileData.FileName}\"" + }; + await blobClient.SetHttpHeadersAsync(headers); + + var length = blobProperties.Value.ContentLength; + var valid = minimum <= length && length <= maximum; + + return (valid, length); + } + catch (Exception ex) + { + _logger.LogError(ex, "A storage operation failed in {MethodName}", nameof(ValidateFileAsync)); + return (false, -1); + } + } + + public async Task DeleteBlobAsync(string blobName) + { + await InitAsync(); + var blobClient = _containerClient!.GetBlobClient(blobName); + await blobClient.DeleteIfExistsAsync(); + } + + public async Task DeleteReportFilesAsync(OrganizationReport report, string reportFileId) + { + await InitAsync(); + var prefix = $"{report.OrganizationId}/{report.CreationDate:MM-dd-yyyy}/{report.Id}/{reportFileId}/"; + await foreach (var blobItem in _containerClient!.GetBlobsAsync(prefix: prefix)) + { + var blobClient = _containerClient.GetBlobClient(blobItem.Name); + await blobClient.DeleteIfExistsAsync(); + } + } + + internal static string BlobPath(OrganizationReport report, string fileId, string fileName) + { + var date = report.CreationDate.ToString("MM-dd-yyyy"); + return $"{report.OrganizationId}/{date}/{report.Id}/{fileId}/{fileName}"; + } + + private async Task InitAsync() + { + if (_containerClient == null) + { + _containerClient = _blobServiceClient.GetBlobContainerClient(ContainerName); + await _containerClient.CreateIfNotExistsAsync(PublicAccessType.None); + } + } +} diff --git a/src/Core/Dirt/Reports/Services/IOrganizationReportStorageService.cs b/src/Core/Dirt/Reports/Services/IOrganizationReportStorageService.cs new file mode 100644 index 000000000000..888933f9d400 --- /dev/null +++ b/src/Core/Dirt/Reports/Services/IOrganizationReportStorageService.cs @@ -0,0 +1,20 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Models.Data; +using Bit.Core.Enums; + +namespace Bit.Core.Dirt.Reports.Services; + +public interface IOrganizationReportStorageService +{ + FileUploadType FileUploadType { get; } + + Task GetReportFileUploadUrlAsync(OrganizationReport report, ReportFile fileData); + + Task GetReportDataDownloadUrlAsync(OrganizationReport report, ReportFile fileData); + + Task UploadReportDataAsync(OrganizationReport report, ReportFile fileData, Stream stream); + + Task<(bool valid, long length)> ValidateFileAsync(OrganizationReport report, ReportFile fileData, long minimum, long maximum); + + Task DeleteReportFilesAsync(OrganizationReport report, string reportFileId); +} diff --git a/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs b/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs new file mode 100644 index 000000000000..27a44a3070e6 --- /dev/null +++ b/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs @@ -0,0 +1,95 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Models.Data; +using Bit.Core.Enums; +using Bit.Core.Settings; + +namespace Bit.Core.Dirt.Reports.Services; + +public class LocalOrganizationReportStorageService : IOrganizationReportStorageService +{ + private readonly string _baseDirPath; + private readonly string _baseUrl; + + public FileUploadType FileUploadType => FileUploadType.Direct; + + public LocalOrganizationReportStorageService(GlobalSettings globalSettings) + { + _baseDirPath = globalSettings.OrganizationReport.BaseDirectory; + _baseUrl = globalSettings.OrganizationReport.BaseUrl; + } + + public Task GetReportFileUploadUrlAsync(OrganizationReport report, ReportFile fileData) + => Task.FromResult($"/reports/organizations/{report.OrganizationId}/{report.Id}/file/report-data"); + + public Task GetReportDataDownloadUrlAsync(OrganizationReport report, ReportFile fileData) + { + InitDir(); + return Task.FromResult($"{_baseUrl}/{RelativePath(report, fileData.Id!, fileData.FileName)}"); + } + + public async Task UploadReportDataAsync(OrganizationReport report, ReportFile fileData, Stream stream) + => await WriteFileAsync(report, fileData.Id!, fileData.FileName, stream); + + public Task<(bool valid, long length)> ValidateFileAsync( + OrganizationReport report, ReportFile fileData, long minimum, long maximum) + { + var path = Path.Combine(_baseDirPath, RelativePath(report, fileData.Id!, fileData.FileName)); + EnsurePathWithinBaseDir(path); + if (!File.Exists(path)) + { + return Task.FromResult((false, -1L)); + } + + var length = new FileInfo(path).Length; + var valid = minimum <= length && length <= maximum; + return Task.FromResult((valid, length)); + } + + public Task DeleteReportFilesAsync(OrganizationReport report, string reportFileId) + { + var dirPath = Path.Combine(_baseDirPath, report.OrganizationId.ToString(), + report.CreationDate.ToString("MM-dd-yyyy"), report.Id.ToString(), reportFileId); + EnsurePathWithinBaseDir(dirPath); + if (Directory.Exists(dirPath)) + { + Directory.Delete(dirPath, true); + } + return Task.CompletedTask; + } + + private async Task WriteFileAsync(OrganizationReport report, string fileId, string fileName, Stream stream) + { + InitDir(); + var path = Path.Combine(_baseDirPath, RelativePath(report, fileId, fileName)); + EnsurePathWithinBaseDir(path); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + using var fs = File.Create(path); + stream.Seek(0, SeekOrigin.Begin); + await stream.CopyToAsync(fs); + } + + private static string RelativePath(OrganizationReport report, string fileId, string fileName) + { + var date = report.CreationDate.ToString("MM-dd-yyyy"); + return Path.Combine(report.OrganizationId.ToString(), date, report.Id.ToString(), + fileId, fileName); + } + + private void EnsurePathWithinBaseDir(string path) + { + var fullPath = Path.GetFullPath(path); + var fullBaseDir = Path.GetFullPath(_baseDirPath + Path.DirectorySeparatorChar); + if (!fullPath.StartsWith(fullBaseDir, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Path traversal detected."); + } + } + + private void InitDir() + { + if (!Directory.Exists(_baseDirPath)) + { + Directory.CreateDirectory(_baseDirPath); + } + } +} diff --git a/src/Core/Dirt/Reports/Services/NoopOrganizationReportStorageService.cs b/src/Core/Dirt/Reports/Services/NoopOrganizationReportStorageService.cs new file mode 100644 index 000000000000..fb56ced538cf --- /dev/null +++ b/src/Core/Dirt/Reports/Services/NoopOrganizationReportStorageService.cs @@ -0,0 +1,20 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Models.Data; +using Bit.Core.Enums; + +namespace Bit.Core.Dirt.Reports.Services; + +public class NoopOrganizationReportStorageService : IOrganizationReportStorageService +{ + public FileUploadType FileUploadType => FileUploadType.Direct; + + public Task GetReportFileUploadUrlAsync(OrganizationReport report, ReportFile fileData) => Task.FromResult(string.Empty); + + public Task GetReportDataDownloadUrlAsync(OrganizationReport report, ReportFile fileData) => Task.FromResult(string.Empty); + + public Task UploadReportDataAsync(OrganizationReport report, ReportFile fileData, Stream stream) => Task.CompletedTask; + + public Task<(bool valid, long length)> ValidateFileAsync(OrganizationReport report, ReportFile fileData, long minimum, long maximum) => Task.FromResult((true, fileData.Size)); + + public Task DeleteReportFilesAsync(OrganizationReport report, string reportFileId) => Task.CompletedTask; +} diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index 1eb7d28b9cf7..3ec2ecf0aa8e 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -17,6 +17,7 @@ public GlobalSettings() BaseServiceUri = new BaseServiceUriSettings(this); Attachment = new FileStorageSettings(this, "attachments", "attachments"); Send = new FileStorageSettings(this, "attachments/send", "attachments/send"); + OrganizationReport = new FileStorageSettings(this, "attachments/reports", "attachments/reports"); DataProtection = new DataProtectionSettings(this); } @@ -65,6 +66,7 @@ public virtual string MailTemplateDirectory public virtual NotificationsSettings Notifications { get; set; } = new NotificationsSettings(); public virtual IFileStorageSettings Attachment { get; set; } public virtual FileStorageSettings Send { get; set; } + public virtual FileStorageSettings OrganizationReport { get; set; } public virtual IdentityServerSettings IdentityServer { get; set; } = new IdentityServerSettings(); public virtual DataProtectionSettings DataProtection { get; set; } public virtual NotificationHubPoolSettings NotificationHubPool { get; set; } = new(); diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 18675169d550..e7075eaa7fd9 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -27,6 +27,7 @@ using Bit.Core.Billing.Services.Implementations; using Bit.Core.Billing.TrialInitiation; using Bit.Core.Dirt.Reports.ReportFeatures; +using Bit.Core.Dirt.Reports.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.HostedServices; @@ -361,6 +362,19 @@ public static void AddDefaultServices(this IServiceCollection services, GlobalSe { services.AddSingleton(); } + + if (CoreHelpers.SettingHasValue(globalSettings.OrganizationReport.ConnectionString)) + { + services.AddSingleton(); + } + else if (CoreHelpers.SettingHasValue(globalSettings.OrganizationReport.BaseDirectory)) + { + services.AddSingleton(); + } + else + { + services.AddSingleton(); + } } public static void AddOosServices(this IServiceCollection services) diff --git a/test/Api.Test/Dirt/Models/Response/OrganizationReportResponseModelTests.cs b/test/Api.Test/Dirt/Models/Response/OrganizationReportResponseModelTests.cs new file mode 100644 index 000000000000..2d67407a050d --- /dev/null +++ b/test/Api.Test/Dirt/Models/Response/OrganizationReportResponseModelTests.cs @@ -0,0 +1,34 @@ +using Bit.Api.Dirt.Models.Response; +using Bit.Core.Dirt.Entities; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Api.Test.Dirt.Models.Response; + +public class OrganizationReportResponseModelTests +{ + [Theory, BitAutoData] + public void Constructor_MapsPropertiesFromEntity(OrganizationReport report) + { + report.ReportFile = null; + var model = new OrganizationReportResponseModel(report); + + Assert.Equal(report.Id, model.Id); + Assert.Equal(report.OrganizationId, model.OrganizationId); + Assert.Equal(report.ReportData, model.ReportData); + Assert.Equal(report.ContentEncryptionKey, model.ContentEncryptionKey); + Assert.Equal(report.SummaryData, model.SummaryData); + Assert.Equal(report.ApplicationData, model.ApplicationData); + Assert.Equal(report.CreationDate, model.CreationDate); + Assert.Equal(report.RevisionDate, model.RevisionDate); + } + + [Theory, BitAutoData] + public void Constructor_FileIsNull(OrganizationReport report) + { + report.ReportFile = null; + var model = new OrganizationReportResponseModel(report); + + Assert.Null(model.ReportFile); + } +} diff --git a/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs b/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs index 880be1e4d9d1..f179524f782c 100644 --- a/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs +++ b/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs @@ -1,11 +1,16 @@ using Bit.Api.Dirt.Controllers; using Bit.Api.Dirt.Models.Response; +using Bit.Core; using Bit.Core.Context; using Bit.Core.Dirt.Entities; using Bit.Core.Dirt.Models.Data; using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Reports.Services; +using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Mvc; @@ -18,15 +23,21 @@ namespace Bit.Api.Test.Dirt; [SutProviderCustomize] public class OrganizationReportControllerTests { - #region Whole OrganizationReport Endpoints + // GetLatestOrganizationReportAsync - V1 (flag off) [Theory, BitAutoData] - public async Task GetLatestOrganizationReportAsync_WithValidOrgId_ReturnsOkResult( + public async Task GetLatestOrganizationReportAsync_V1_WithValidOrgId_ReturnsOkResult( SutProvider sutProvider, Guid orgId, OrganizationReport expectedReport) { // Arrange + expectedReport.ReportFile = null; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); + sutProvider.GetDependency() .AccessReports(orgId) .Returns(true); @@ -45,31 +56,38 @@ public async Task GetLatestOrganizationReportAsync_WithValidOrgId_ReturnsOkResul } [Theory, BitAutoData] - public async Task GetLatestOrganizationReportAsync_WithoutAccess_ThrowsNotFoundException( + public async Task GetLatestOrganizationReportAsync_V1_WithoutAccess_ThrowsNotFoundException( SutProvider sutProvider, Guid orgId) { // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); + sutProvider.GetDependency() .AccessReports(orgId) - .Returns(Task.FromResult(false)); + .Returns(false); // Act & Assert await Assert.ThrowsAsync(() => sutProvider.Sut.GetLatestOrganizationReportAsync(orgId)); - // Verify that the query was not called await sutProvider.GetDependency() .DidNotReceive() .GetLatestOrganizationReportAsync(Arg.Any()); } [Theory, BitAutoData] - public async Task GetLatestOrganizationReportAsync_WhenNoReportFound_ReturnsOkWithNull( + public async Task GetLatestOrganizationReportAsync_V1_WhenNoReportFound_ReturnsOkWithNull( SutProvider sutProvider, Guid orgId) { // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); + sutProvider.GetDependency() .AccessReports(orgId) .Returns(true); @@ -86,39 +104,352 @@ public async Task GetLatestOrganizationReportAsync_WhenNoReportFound_ReturnsOkWi Assert.Null(okResult.Value); } + // GetLatestOrganizationReportAsync - V2 (flag on) + + [Theory, BitAutoData] + public async Task GetLatestOrganizationReportAsync_V2_WithValidatedFile_ReturnsOkWithDownloadUrl( + SutProvider sutProvider, + Guid orgId, + OrganizationReport expectedReport, + string downloadUrl) + { + // Arrange + var reportFile = new ReportFile { Id = "file-id", FileName = "report.json", Size = 1024, Validated = true }; + expectedReport.SetReportFile(reportFile); + + SetupV2Authorization(sutProvider, orgId); + + sutProvider.GetDependency() + .GetLatestOrganizationReportAsync(orgId) + .Returns(expectedReport); + + sutProvider.GetDependency() + .GetReportDataDownloadUrlAsync(expectedReport, Arg.Any()) + .Returns(downloadUrl); + + // Act + var result = await sutProvider.Sut.GetLatestOrganizationReportAsync(orgId); + + // Assert + var okResult = Assert.IsType(result); + var response = Assert.IsType(okResult.Value); + Assert.Equal(downloadUrl, response.ReportFileDownloadUrl); + } + [Theory, BitAutoData] - public async Task GetLatestOrganizationReportAsync_CallsCorrectMethods( + public async Task GetLatestOrganizationReportAsync_V2_WithNoFile_ReturnsOkWithNullDownloadUrl( SutProvider sutProvider, Guid orgId, OrganizationReport expectedReport) { // Arrange - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); + expectedReport.ReportFile = null; + + SetupV2Authorization(sutProvider, orgId); sutProvider.GetDependency() .GetLatestOrganizationReportAsync(orgId) .Returns(expectedReport); // Act - await sutProvider.Sut.GetLatestOrganizationReportAsync(orgId); + var result = await sutProvider.Sut.GetLatestOrganizationReportAsync(orgId); // Assert - await sutProvider.GetDependency() - .Received(1) - .AccessReports(orgId); + var okResult = Assert.IsType(result); + var response = Assert.IsType(okResult.Value); + Assert.Null(response.ReportFileDownloadUrl); + } + + [Theory, BitAutoData] + public async Task GetLatestOrganizationReportAsync_V2_NoReport_ReturnsOkWithNull( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + SetupV2Authorization(sutProvider, orgId); + + sutProvider.GetDependency() + .GetLatestOrganizationReportAsync(orgId) + .Returns((OrganizationReport)null); + + // Act + var result = await sutProvider.Sut.GetLatestOrganizationReportAsync(orgId); + + // Assert + var okResult = Assert.IsType(result); + Assert.Null(okResult.Value); + } + + [Theory, BitAutoData] + public async Task GetLatestOrganizationReportAsync_V2_WithoutAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(true); + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(false); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetLatestOrganizationReportAsync(orgId)); await sutProvider.GetDependency() - .Received(1) - .GetLatestOrganizationReportAsync(orgId); + .DidNotReceive() + .GetLatestOrganizationReportAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task GetLatestOrganizationReportAsync_V2_NoUseRiskInsights_ThrowsBadRequestException( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(true); + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(orgId) + .Returns(new OrganizationAbility { UseRiskInsights = false }); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetLatestOrganizationReportAsync(orgId)); + + await sutProvider.GetDependency() + .DidNotReceive() + .GetLatestOrganizationReportAsync(Arg.Any()); + } + + // CreateOrganizationReportAsync - V1 (flag off) + + [Theory, BitAutoData] + public async Task CreateOrganizationReportAsync_V1_WithValidRequest_ReturnsOkResult( + SutProvider sutProvider, + Guid orgId, + AddOrganizationReportRequest request, + OrganizationReport expectedReport) + { + // Arrange + request.OrganizationId = orgId; + expectedReport.ReportFile = null; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .AddOrganizationReportAsync(request) + .Returns(expectedReport); + + // Act + var result = await sutProvider.Sut.CreateOrganizationReportAsync(orgId, request); + + // Assert + var okResult = Assert.IsType(result); + var expectedResponse = new OrganizationReportResponseModel(expectedReport); + Assert.Equivalent(expectedResponse, okResult.Value); + } + + [Theory, BitAutoData] + public async Task CreateOrganizationReportAsync_V1_WithoutAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + AddOrganizationReportRequest request) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(false); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.CreateOrganizationReportAsync(orgId, request)); + + await sutProvider.GetDependency() + .DidNotReceive() + .AddOrganizationReportAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task CreateOrganizationReportAsync_V1_WithMismatchedOrgId_ThrowsBadRequestException( + SutProvider sutProvider, + Guid orgId, + AddOrganizationReportRequest request) + { + // Arrange + request.OrganizationId = Guid.NewGuid(); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.CreateOrganizationReportAsync(orgId, request)); + + Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); + + await sutProvider.GetDependency() + .DidNotReceive() + .AddOrganizationReportAsync(Arg.Any()); + } + + // CreateOrganizationReportAsync - V2 (flag on) + + [Theory, BitAutoData] + public async Task CreateOrganizationReportAsync_V2_WithValidRequest_ReturnsFileResponseModel( + SutProvider sutProvider, + Guid orgId, + AddOrganizationReportRequest request, + OrganizationReport expectedReport, + string uploadUrl) + { + // Arrange + request.OrganizationId = orgId; + request.FileSize = 1024; + + var reportFile = new ReportFile { Id = "file-id", FileName = "report.json", Size = 1024, Validated = false }; + expectedReport.SetReportFile(reportFile); + + SetupV2Authorization(sutProvider, orgId); + + sutProvider.GetDependency() + .CreateAsync(request) + .Returns(expectedReport); + + sutProvider.GetDependency() + .GetReportFileUploadUrlAsync(expectedReport, Arg.Any()) + .Returns(uploadUrl); + + sutProvider.GetDependency() + .FileUploadType + .Returns(FileUploadType.Azure); + + // Act + var result = await sutProvider.Sut.CreateOrganizationReportAsync(orgId, request); + + // Assert + var okResult = Assert.IsType(result); + var response = Assert.IsType(okResult.Value); + Assert.Equal(uploadUrl, response.ReportFileUploadUrl); + Assert.Equal(FileUploadType.Azure, response.FileUploadType); + Assert.NotNull(response.ReportResponse); + } + + [Theory, BitAutoData] + public async Task CreateOrganizationReportAsync_V2_EmptyOrgId_ThrowsBadRequestException( + SutProvider sutProvider, + AddOrganizationReportRequest request) + { + // Arrange + var emptyOrgId = Guid.Empty; + request.OrganizationId = emptyOrgId; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.CreateOrganizationReportAsync(emptyOrgId, request)); + + Assert.Equal("Organization ID is required.", exception.Message); + } + + [Theory, BitAutoData] + public async Task CreateOrganizationReportAsync_V2_MismatchedOrgId_ThrowsBadRequestException( + SutProvider sutProvider, + Guid orgId, + AddOrganizationReportRequest request) + { + // Arrange + request.OrganizationId = Guid.NewGuid(); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.CreateOrganizationReportAsync(orgId, request)); + + Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); + } + + [Theory, BitAutoData] + public async Task CreateOrganizationReportAsync_V2_MissingFileSize_ThrowsBadRequestException( + SutProvider sutProvider, + Guid orgId, + AddOrganizationReportRequest request) + { + // Arrange + request.OrganizationId = orgId; + request.FileSize = null; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.CreateOrganizationReportAsync(orgId, request)); + + Assert.Equal("File size is required.", exception.Message); } + [Theory, BitAutoData] + public async Task CreateOrganizationReportAsync_V2_WithoutAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + AddOrganizationReportRequest request) + { + // Arrange + request.OrganizationId = orgId; + request.FileSize = 1024; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(true); + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(false); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.CreateOrganizationReportAsync(orgId, request)); + await sutProvider.GetDependency() + .DidNotReceive() + .CreateAsync(Arg.Any()); + } + // GetOrganizationReportAsync - V1 (flag off) [Theory, BitAutoData] - public async Task GetOrganizationReportAsync_WithValidIds_ReturnsOkResult( + public async Task GetOrganizationReportAsync_V1_WithValidIds_ReturnsOkResult( SutProvider sutProvider, Guid orgId, Guid reportId, @@ -126,6 +457,11 @@ public async Task GetOrganizationReportAsync_WithValidIds_ReturnsOkResult( { // Arrange expectedReport.OrganizationId = orgId; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); + sutProvider.GetDependency() .AccessReports(orgId) .Returns(true); @@ -143,33 +479,40 @@ public async Task GetOrganizationReportAsync_WithValidIds_ReturnsOkResult( } [Theory, BitAutoData] - public async Task GetOrganizationReportAsync_WithoutAccess_ThrowsNotFoundException( + public async Task GetOrganizationReportAsync_V1_WithoutAccess_ThrowsNotFoundException( SutProvider sutProvider, Guid orgId, Guid reportId) { // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); + sutProvider.GetDependency() .AccessReports(orgId) - .Returns(Task.FromResult(false)); + .Returns(false); // Act & Assert await Assert.ThrowsAsync(() => sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId)); - // Verify that the query was not called await sutProvider.GetDependency() .DidNotReceive() .GetOrganizationReportAsync(Arg.Any()); } [Theory, BitAutoData] - public async Task GetOrganizationReportAsync_WhenReportNotFound_ThrowsNotFoundException( + public async Task GetOrganizationReportAsync_V1_WhenReportNotFound_ThrowsNotFoundException( SutProvider sutProvider, Guid orgId, Guid reportId) { // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); + sutProvider.GetDependency() .AccessReports(orgId) .Returns(true); @@ -186,14 +529,19 @@ public async Task GetOrganizationReportAsync_WhenReportNotFound_ThrowsNotFoundEx } [Theory, BitAutoData] - public async Task GetOrganizationReportAsync_CallsCorrectMethods( + public async Task GetOrganizationReportAsync_V1_WithOrgMismatch_ThrowsBadRequestException( SutProvider sutProvider, Guid orgId, Guid reportId, OrganizationReport expectedReport) { // Arrange - expectedReport.OrganizationId = orgId; + expectedReport.OrganizationId = Guid.NewGuid(); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); + sutProvider.GetDependency() .AccessReports(orgId) .Returns(true); @@ -202,205 +550,198 @@ public async Task GetOrganizationReportAsync_CallsCorrectMethods( .GetOrganizationReportAsync(reportId) .Returns(expectedReport); - // Act - await sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId); - - // Assert - await sutProvider.GetDependency() - .Received(1) - .AccessReports(orgId); + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId)); - await sutProvider.GetDependency() - .Received(1) - .GetOrganizationReportAsync(reportId); + Assert.Equal("Invalid report ID", exception.Message); } + // GetOrganizationReportAsync - V2 (flag on) + [Theory, BitAutoData] - public async Task GetOrganizationReportAsync_WithValidAccess_UsesCorrectReportId( + public async Task GetOrganizationReportAsync_V2_WithValidatedFile_ReturnsOkWithDownloadUrl( SutProvider sutProvider, Guid orgId, Guid reportId, - OrganizationReport expectedReport) + OrganizationReport expectedReport, + string downloadUrl) { // Arrange expectedReport.OrganizationId = orgId; - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); + var reportFile = new ReportFile { Id = "file-id", FileName = "report.json", Size = 1024, Validated = true }; + expectedReport.SetReportFile(reportFile); + + SetupV2Authorization(sutProvider, orgId); sutProvider.GetDependency() .GetOrganizationReportAsync(reportId) .Returns(expectedReport); + sutProvider.GetDependency() + .GetReportDataDownloadUrlAsync(expectedReport, Arg.Any()) + .Returns(downloadUrl); + // Act - await sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId); + var result = await sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId); // Assert - await sutProvider.GetDependency() - .Received(1) - .GetOrganizationReportAsync(reportId); + var okResult = Assert.IsType(result); + var response = Assert.IsType(okResult.Value); + Assert.Equal(downloadUrl, response.ReportFileDownloadUrl); } [Theory, BitAutoData] - public async Task CreateOrganizationReportAsync_WithValidRequest_ReturnsOkResult( + public async Task GetOrganizationReportAsync_V2_WithNoFile_ReturnsOkWithoutDownloadUrl( SutProvider sutProvider, Guid orgId, - AddOrganizationReportRequest request, + Guid reportId, OrganizationReport expectedReport) { // Arrange - request.OrganizationId = orgId; + expectedReport.OrganizationId = orgId; + expectedReport.ReportFile = null; - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); + SetupV2Authorization(sutProvider, orgId); - sutProvider.GetDependency() - .AddOrganizationReportAsync(request) + sutProvider.GetDependency() + .GetOrganizationReportAsync(reportId) .Returns(expectedReport); // Act - var result = await sutProvider.Sut.CreateOrganizationReportAsync(orgId, request); + var result = await sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId); // Assert var okResult = Assert.IsType(result); - var expectedResponse = new OrganizationReportResponseModel(expectedReport); - Assert.Equivalent(expectedResponse, okResult.Value); + var response = Assert.IsType(okResult.Value); + Assert.Null(response.ReportFileDownloadUrl); } [Theory, BitAutoData] - public async Task CreateOrganizationReportAsync_WithoutAccess_ThrowsNotFoundException( + public async Task GetOrganizationReportAsync_V2_WithOrgMismatch_ThrowsBadRequestException( SutProvider sutProvider, Guid orgId, - AddOrganizationReportRequest request) + Guid reportId, + OrganizationReport expectedReport) { // Arrange - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(false); - - // Act & Assert - await Assert.ThrowsAsync(() => - sutProvider.Sut.CreateOrganizationReportAsync(orgId, request)); + expectedReport.OrganizationId = Guid.NewGuid(); - // Verify that the command was not called - await sutProvider.GetDependency() - .DidNotReceive() - .AddOrganizationReportAsync(Arg.Any()); - } + SetupV2Authorization(sutProvider, orgId); - [Theory, BitAutoData] - public async Task CreateOrganizationReportAsync_WithMismatchedOrgId_ThrowsBadRequestException( - SutProvider sutProvider, - Guid orgId, - AddOrganizationReportRequest request) - { - // Arrange - request.OrganizationId = Guid.NewGuid(); // Different from orgId - - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); + sutProvider.GetDependency() + .GetOrganizationReportAsync(reportId) + .Returns(expectedReport); // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.CreateOrganizationReportAsync(orgId, request)); - - Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); + sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId)); - // Verify that the command was not called - await sutProvider.GetDependency() - .DidNotReceive() - .AddOrganizationReportAsync(Arg.Any()); + Assert.Equal("Invalid report ID", exception.Message); } [Theory, BitAutoData] - public async Task CreateOrganizationReportAsync_CallsCorrectMethods( + public async Task GetOrganizationReportAsync_V2_WithoutAccess_ThrowsNotFoundException( SutProvider sutProvider, Guid orgId, - AddOrganizationReportRequest request, - OrganizationReport expectedReport) + Guid reportId) { // Arrange - request.OrganizationId = orgId; + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(true); sutProvider.GetDependency() .AccessReports(orgId) - .Returns(true); - - sutProvider.GetDependency() - .AddOrganizationReportAsync(request) - .Returns(expectedReport); - - // Act - await sutProvider.Sut.CreateOrganizationReportAsync(orgId, request); + .Returns(false); - // Assert - await sutProvider.GetDependency() - .Received(1) - .AccessReports(orgId); + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId)); - await sutProvider.GetDependency() - .Received(1) - .AddOrganizationReportAsync(request); + await sutProvider.GetDependency() + .DidNotReceive() + .GetOrganizationReportAsync(Arg.Any()); } + // UpdateOrganizationReportAsync - V1 (flag off) + [Theory, BitAutoData] - public async Task UpdateOrganizationReportAsync_WithValidRequest_ReturnsOkResult( + public async Task UpdateOrganizationReportAsync_V1_WithValidRequest_ReturnsOkResult( SutProvider sutProvider, Guid orgId, - UpdateOrganizationReportRequest request, + Guid reportId, + UpdateOrganizationReportV2Request request, OrganizationReport expectedReport) { // Arrange request.OrganizationId = orgId; + expectedReport.ReportFile = null; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); sutProvider.GetDependency() .AccessReports(orgId) .Returns(true); sutProvider.GetDependency() - .UpdateOrganizationReportAsync(request) + .UpdateOrganizationReportAsync(Arg.Any()) .Returns(expectedReport); // Act - var result = await sutProvider.Sut.UpdateOrganizationReportAsync(orgId, request); + var result = await sutProvider.Sut.UpdateOrganizationReportAsync(orgId, reportId, request); // Assert var okResult = Assert.IsType(result); var expectedResponse = new OrganizationReportResponseModel(expectedReport); Assert.Equivalent(expectedResponse, okResult.Value); + + await sutProvider.GetDependency() + .Received(1) + .UpdateOrganizationReportAsync(Arg.Is(r => + r.OrganizationId == orgId && r.ReportId == reportId)); } [Theory, BitAutoData] - public async Task UpdateOrganizationReportAsync_WithoutAccess_ThrowsNotFoundException( + public async Task UpdateOrganizationReportAsync_V1_WithoutAccess_ThrowsNotFoundException( SutProvider sutProvider, Guid orgId, - UpdateOrganizationReportRequest request) + Guid reportId, + UpdateOrganizationReportV2Request request) { // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); + sutProvider.GetDependency() .AccessReports(orgId) .Returns(false); // Act & Assert await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateOrganizationReportAsync(orgId, request)); + sutProvider.Sut.UpdateOrganizationReportAsync(orgId, reportId, request)); - // Verify that the command was not called await sutProvider.GetDependency() .DidNotReceive() .UpdateOrganizationReportAsync(Arg.Any()); } [Theory, BitAutoData] - public async Task UpdateOrganizationReportAsync_WithMismatchedOrgId_ThrowsBadRequestException( + public async Task UpdateOrganizationReportAsync_V1_WithMismatchedOrgId_ThrowsBadRequestException( SutProvider sutProvider, Guid orgId, - UpdateOrganizationReportRequest request) + Guid reportId, + UpdateOrganizationReportV2Request request) { // Arrange - request.OrganizationId = Guid.NewGuid(); // Different from orgId + request.OrganizationId = Guid.NewGuid(); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); sutProvider.GetDependency() .AccessReports(orgId) @@ -408,50 +749,113 @@ public async Task UpdateOrganizationReportAsync_WithMismatchedOrgId_ThrowsBadReq // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateOrganizationReportAsync(orgId, request)); + sutProvider.Sut.UpdateOrganizationReportAsync(orgId, reportId, request)); Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); - // Verify that the command was not called await sutProvider.GetDependency() .DidNotReceive() .UpdateOrganizationReportAsync(Arg.Any()); } + // UpdateOrganizationReportAsync - V2 (flag on) + [Theory, BitAutoData] - public async Task UpdateOrganizationReportAsync_CallsCorrectMethods( + public async Task UpdateOrganizationReportAsync_V2_NoNewFileUpload_ReturnsReportResponseModel( SutProvider sutProvider, Guid orgId, - UpdateOrganizationReportRequest request, + Guid reportId, + UpdateOrganizationReportV2Request request, OrganizationReport expectedReport) { // Arrange - request.OrganizationId = orgId; + request.RequiresNewFileUpload = false; + expectedReport.ReportFile = null; - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); + SetupV2Authorization(sutProvider, orgId); - sutProvider.GetDependency() - .UpdateOrganizationReportAsync(request) + sutProvider.GetDependency() + .UpdateAsync(request) .Returns(expectedReport); // Act - await sutProvider.Sut.UpdateOrganizationReportAsync(orgId, request); + var result = await sutProvider.Sut.UpdateOrganizationReportAsync(orgId, reportId, request); // Assert - await sutProvider.GetDependency() - .Received(1) - .AccessReports(orgId); + var okResult = Assert.IsType(result); + Assert.IsType(okResult.Value); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) - .UpdateOrganizationReportAsync(request); + .UpdateAsync(request); } - #endregion + [Theory, BitAutoData] + public async Task UpdateOrganizationReportAsync_V2_WithNewFileUpload_ReturnsFileResponseModel( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + UpdateOrganizationReportV2Request request, + OrganizationReport expectedReport, + string uploadUrl) + { + // Arrange + request.RequiresNewFileUpload = true; + + var reportFile = new ReportFile { Id = "file-id", FileName = "report.json", Size = 1024, Validated = false }; + expectedReport.SetReportFile(reportFile); + + SetupV2Authorization(sutProvider, orgId); - #region SummaryData Field Endpoints + sutProvider.GetDependency() + .UpdateAsync(request) + .Returns(expectedReport); + + sutProvider.GetDependency() + .GetReportFileUploadUrlAsync(expectedReport, Arg.Any()) + .Returns(uploadUrl); + + sutProvider.GetDependency() + .FileUploadType + .Returns(FileUploadType.Azure); + + // Act + var result = await sutProvider.Sut.UpdateOrganizationReportAsync(orgId, reportId, request); + + // Assert + var okResult = Assert.IsType(result); + var response = Assert.IsType(okResult.Value); + Assert.Equal(uploadUrl, response.ReportFileUploadUrl); + Assert.Equal(FileUploadType.Azure, response.FileUploadType); + Assert.NotNull(response.ReportResponse); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportAsync_V2_WithoutAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + UpdateOrganizationReportV2Request request) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(true); + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(false); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.UpdateOrganizationReportAsync(orgId, reportId, request)); + + await sutProvider.GetDependency() + .DidNotReceive() + .UpdateAsync(Arg.Any()); + } + + // SummaryData Field Endpoints [Theory, BitAutoData] public async Task GetOrganizationReportSummaryDataByDateRangeAsync_WithValidParameters_ReturnsOkResult( @@ -587,6 +991,7 @@ public async Task UpdateOrganizationReportSummaryAsync_WithValidRequest_ReturnsO // Arrange request.OrganizationId = orgId; request.ReportId = reportId; + expectedReport.ReportFile = null; sutProvider.GetDependency() .AccessReports(orgId) @@ -692,6 +1097,7 @@ public async Task UpdateOrganizationReportSummaryAsync_CallsCorrectMethods( // Arrange request.OrganizationId = orgId; request.ReportId = reportId; + expectedReport.ReportFile = null; sutProvider.GetDependency() .AccessReports(orgId) @@ -714,9 +1120,7 @@ await sutProvider.GetDependency() .UpdateOrganizationReportSummaryAsync(request); } - #endregion - - #region ReportData Field Endpoints + // ReportData Field Endpoints [Theory, BitAutoData] public async Task GetOrganizationReportDataAsync_WithValidIds_ReturnsOkResult( @@ -803,6 +1207,7 @@ public async Task UpdateOrganizationReportDataAsync_WithValidRequest_ReturnsOkRe // Arrange request.OrganizationId = orgId; request.ReportId = reportId; + expectedReport.ReportFile = null; sutProvider.GetDependency() .AccessReports(orgId) @@ -908,6 +1313,7 @@ public async Task UpdateOrganizationReportDataAsync_CallsCorrectMethods( // Arrange request.OrganizationId = orgId; request.ReportId = reportId; + expectedReport.ReportFile = null; sutProvider.GetDependency() .AccessReports(orgId) @@ -930,9 +1336,7 @@ await sutProvider.GetDependency() .UpdateOrganizationReportDataAsync(request); } - #endregion - - #region ApplicationData Field Endpoints + // ApplicationData Field Endpoints [Theory, BitAutoData] public async Task GetOrganizationReportApplicationDataAsync_WithValidIds_ReturnsOkResult( @@ -1042,6 +1446,7 @@ public async Task UpdateOrganizationReportApplicationDataAsync_WithValidRequest_ request.OrganizationId = orgId; request.Id = reportId; expectedReport.Id = request.Id; + expectedReport.ReportFile = null; sutProvider.GetDependency() .AccessReports(orgId) @@ -1146,6 +1551,7 @@ public async Task UpdateOrganizationReportApplicationDataAsync_CallsCorrectMetho request.OrganizationId = orgId; request.Id = reportId; expectedReport.Id = reportId; + expectedReport.ReportFile = null; sutProvider.GetDependency() .AccessReports(orgId) @@ -1168,5 +1574,22 @@ await sutProvider.GetDependency .UpdateOrganizationReportApplicationDataAsync(request); } - #endregion + // Helper method for setting up V2 authorization mocks + + private static void SetupV2Authorization( + SutProvider sutProvider, + Guid orgId) + { + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(true); + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(orgId) + .Returns(new OrganizationAbility { UseRiskInsights = true }); + } } diff --git a/test/Common/AutoFixture/GlobalSettingsFixtures.cs b/test/Common/AutoFixture/GlobalSettingsFixtures.cs index 3a2a319eec37..04430be18f74 100644 --- a/test/Common/AutoFixture/GlobalSettingsFixtures.cs +++ b/test/Common/AutoFixture/GlobalSettingsFixtures.cs @@ -10,6 +10,7 @@ public void Customize(IFixture fixture) .Without(s => s.BaseServiceUri) .Without(s => s.Attachment) .Without(s => s.Send) + .Without(s => s.OrganizationReport) .Without(s => s.DataProtection)); } } diff --git a/test/Core.Test/Dirt/Models/Data/ReportFileTests.cs b/test/Core.Test/Dirt/Models/Data/ReportFileTests.cs index db0a1865df88..eeb71955584c 100644 --- a/test/Core.Test/Dirt/Models/Data/ReportFileTests.cs +++ b/test/Core.Test/Dirt/Models/Data/ReportFileTests.cs @@ -14,7 +14,7 @@ public void DefaultValues_AreCorrect() Assert.Null(data.Id); Assert.Equal(string.Empty, data.FileName); Assert.Equal(0, data.Size); - Assert.True(data.Validated); + Assert.False(data.Validated); } [Fact] diff --git a/test/Core.Test/Dirt/ReportFeatures/CreateOrganizationReportCommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/CreateOrganizationReportCommandTests.cs new file mode 100644 index 000000000000..f49d378519a8 --- /dev/null +++ b/test/Core.Test/Dirt/ReportFeatures/CreateOrganizationReportCommandTests.cs @@ -0,0 +1,138 @@ +using AutoFixture; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Dirt.ReportFeatures; + +[SutProviderCustomize] +public class CreateOrganizationReportCommandTests +{ + [Theory] + [BitAutoData] + public async Task CreateAsync_Success_ReturnsReportWithSerializedFileData( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(r => r.ContentEncryptionKey, "test-encryption-key") + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(fixture.Create()); + + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Returns(c => c.Arg()); + + // Act + var report = await sutProvider.Sut.CreateAsync(request); + + // Assert + Assert.NotNull(report); + // ReportFile should contain serialized file data + Assert.NotNull(report.ReportFile); + var fileData = report.GetReportFile(); + Assert.NotNull(fileData); + Assert.NotNull(fileData.Id); + Assert.Equal(32, fileData.Id.Length); + Assert.Matches("^[a-z0-9]+$", fileData.Id); + Assert.False(fileData.Validated); + + Assert.Equal(request.SummaryData, report.SummaryData); + Assert.Equal(request.ApplicationData, report.ApplicationData); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync(Arg.Is(r => + r.OrganizationId == request.OrganizationId && + r.SummaryData == request.SummaryData && + r.ApplicationData == request.ApplicationData && + r.ContentEncryptionKey == "test-encryption-key")); + } + + [Theory] + [BitAutoData] + public async Task CreateAsync_InvalidOrganization_ThrowsBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(r => r.ContentEncryptionKey, "test-key") + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(null as Organization); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + async () => await sutProvider.Sut.CreateAsync(request)); + Assert.Equal("Invalid Organization", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task CreateAsync_MissingContentEncryptionKey_ThrowsBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(r => r.ContentEncryptionKey, string.Empty) + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(fixture.Create()); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + async () => await sutProvider.Sut.CreateAsync(request)); + Assert.Equal("Content Encryption Key is required", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task CreateAsync_WithMetrics_StoresMetricsCorrectly( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var metrics = fixture.Build() + .With(m => m.ApplicationCount, 100) + .With(m => m.MemberCount, 50) + .Create(); + + var request = fixture.Build() + .With(r => r.ContentEncryptionKey, "test-key") + .With(r => r.ReportMetrics, metrics) + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(fixture.Create()); + + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Returns(c => c.Arg()); + + // Act + var report = await sutProvider.Sut.CreateAsync(request); + + // Assert + Assert.Equal(100, report.ApplicationCount); + Assert.Equal(50, report.MemberCount); + } +} diff --git a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportApplicationDataQueryTests.cs b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportApplicationDataQueryTests.cs index c9281d52d130..8d399f07c353 100644 --- a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportApplicationDataQueryTests.cs +++ b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportApplicationDataQueryTests.cs @@ -42,11 +42,9 @@ await sutProvider.GetDependency() [Theory] [BitAutoData] public async Task GetOrganizationReportApplicationDataAsync_WithEmptyOrganizationId_ShouldThrowBadRequestException( + Guid reportId, SutProvider sutProvider) { - // Arrange - var reportId = Guid.NewGuid(); - // Act & Assert var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetOrganizationReportApplicationDataAsync(Guid.Empty, reportId)); @@ -59,11 +57,9 @@ await sutProvider.GetDependency() [Theory] [BitAutoData] public async Task GetOrganizationReportApplicationDataAsync_WithEmptyReportId_ShouldThrowBadRequestException( + Guid organizationId, SutProvider sutProvider) { - // Arrange - var organizationId = Guid.NewGuid(); - // Act & Assert var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetOrganizationReportApplicationDataAsync(organizationId, Guid.Empty)); diff --git a/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportV2CommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportV2CommandTests.cs new file mode 100644 index 000000000000..1761c4060d4f --- /dev/null +++ b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportV2CommandTests.cs @@ -0,0 +1,309 @@ +using AutoFixture; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Dirt.ReportFeatures; + +[SutProviderCustomize] +public class UpdateOrganizationReportV2CommandTests +{ + [Theory] + [BitAutoData] + public async Task UpdateAsync_Success_UpdatesFieldsAndReturnsReport( + SutProvider sutProvider) + { + var fixture = new Fixture(); + var orgId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + + var existingReport = fixture.Build() + .With(r => r.Id, reportId) + .With(r => r.OrganizationId, orgId) + .Without(r => r.ReportFile) + .Create(); + + var request = new UpdateOrganizationReportV2Request + { + ReportId = reportId, + OrganizationId = orgId, + ContentEncryptionKey = "new-key", + SummaryData = "new-summary", + ApplicationData = "new-app-data", + RequiresNewFileUpload = false + }; + + sutProvider.GetDependency() + .GetByIdAsync(orgId) + .Returns(fixture.Create()); + + sutProvider.GetDependency() + .GetByIdAsync(reportId) + .Returns(existingReport); + + var result = await sutProvider.Sut.UpdateAsync(request); + + Assert.NotNull(result); + Assert.Equal("new-key", result.ContentEncryptionKey); + Assert.Equal("new-summary", result.SummaryData); + Assert.Equal("new-app-data", result.ApplicationData); + + await sutProvider.GetDependency() + .Received(1) + .ReplaceAsync(Arg.Is(r => + r.Id == reportId && + r.ContentEncryptionKey == "new-key" && + r.SummaryData == "new-summary" && + r.ApplicationData == "new-app-data")); + } + + [Theory] + [BitAutoData] + public async Task UpdateAsync_WithMetrics_UpdatesMetricFields( + SutProvider sutProvider) + { + var fixture = new Fixture(); + var orgId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + + var existingReport = fixture.Build() + .With(r => r.Id, reportId) + .With(r => r.OrganizationId, orgId) + .Without(r => r.ReportFile) + .Create(); + + var metrics = new OrganizationReportMetrics + { + ApplicationCount = 100, + ApplicationAtRiskCount = 10, + MemberCount = 50, + MemberAtRiskCount = 5, + PasswordCount = 200, + PasswordAtRiskCount = 20 + }; + + var request = new UpdateOrganizationReportV2Request + { + ReportId = reportId, + OrganizationId = orgId, + ReportMetrics = metrics, + RequiresNewFileUpload = false + }; + + sutProvider.GetDependency() + .GetByIdAsync(orgId) + .Returns(fixture.Create()); + + sutProvider.GetDependency() + .GetByIdAsync(reportId) + .Returns(existingReport); + + var result = await sutProvider.Sut.UpdateAsync(request); + + Assert.Equal(100, result.ApplicationCount); + Assert.Equal(10, result.ApplicationAtRiskCount); + Assert.Equal(50, result.MemberCount); + Assert.Equal(5, result.MemberAtRiskCount); + Assert.Equal(200, result.PasswordCount); + Assert.Equal(20, result.PasswordAtRiskCount); + } + + [Theory] + [BitAutoData] + public async Task UpdateAsync_WithRequiresNewFileUpload_CreatesNewReportFile( + SutProvider sutProvider) + { + var fixture = new Fixture(); + var orgId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + + var existingReport = fixture.Build() + .With(r => r.Id, reportId) + .With(r => r.OrganizationId, orgId) + .Without(r => r.ReportFile) + .Create(); + + var request = new UpdateOrganizationReportV2Request + { + ReportId = reportId, + OrganizationId = orgId, + RequiresNewFileUpload = true + }; + + sutProvider.GetDependency() + .GetByIdAsync(orgId) + .Returns(fixture.Create()); + + sutProvider.GetDependency() + .GetByIdAsync(reportId) + .Returns(existingReport); + + var result = await sutProvider.Sut.UpdateAsync(request); + + var fileData = result.GetReportFile(); + Assert.NotNull(fileData); + Assert.NotNull(fileData.Id); + Assert.Equal(32, fileData.Id.Length); + Assert.Equal("report-data.json", fileData.FileName); + Assert.False(fileData.Validated); + Assert.Equal(0, fileData.Size); + } + + [Theory] + [BitAutoData] + public async Task UpdateAsync_NullFields_DoesNotOverwriteExisting( + SutProvider sutProvider) + { + var fixture = new Fixture(); + var orgId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + + var existingReport = fixture.Build() + .With(r => r.Id, reportId) + .With(r => r.OrganizationId, orgId) + .With(r => r.ContentEncryptionKey, "original-key") + .With(r => r.SummaryData, "original-summary") + .With(r => r.ApplicationData, "original-app-data") + .Without(r => r.ReportFile) + .Create(); + + var request = new UpdateOrganizationReportV2Request + { + ReportId = reportId, + OrganizationId = orgId, + ContentEncryptionKey = null, + SummaryData = null, + ApplicationData = null, + RequiresNewFileUpload = false + }; + + sutProvider.GetDependency() + .GetByIdAsync(orgId) + .Returns(fixture.Create()); + + sutProvider.GetDependency() + .GetByIdAsync(reportId) + .Returns(existingReport); + + var result = await sutProvider.Sut.UpdateAsync(request); + + Assert.Equal("original-key", result.ContentEncryptionKey); + Assert.Equal("original-summary", result.SummaryData); + Assert.Equal("original-app-data", result.ApplicationData); + } + + [Theory] + [BitAutoData] + public async Task UpdateAsync_InvalidOrganization_ThrowsBadRequestException( + SutProvider sutProvider) + { + var request = new UpdateOrganizationReportV2Request + { + ReportId = Guid.NewGuid(), + OrganizationId = Guid.NewGuid() + }; + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(null as Organization); + + var exception = await Assert.ThrowsAsync( + async () => await sutProvider.Sut.UpdateAsync(request)); + Assert.Equal("Invalid Organization", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateAsync_ReportNotFound_ThrowsNotFoundException( + SutProvider sutProvider) + { + var fixture = new Fixture(); + var request = new UpdateOrganizationReportV2Request + { + ReportId = Guid.NewGuid(), + OrganizationId = Guid.NewGuid() + }; + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(fixture.Create()); + + sutProvider.GetDependency() + .GetByIdAsync(request.ReportId) + .Returns(null as OrganizationReport); + + await Assert.ThrowsAsync( + async () => await sutProvider.Sut.UpdateAsync(request)); + } + + [Theory] + [BitAutoData] + public async Task UpdateAsync_OrgMismatch_ThrowsBadRequestException( + SutProvider sutProvider) + { + var fixture = new Fixture(); + var request = new UpdateOrganizationReportV2Request + { + ReportId = Guid.NewGuid(), + OrganizationId = Guid.NewGuid() + }; + + var existingReport = fixture.Build() + .With(r => r.Id, request.ReportId) + .With(r => r.OrganizationId, Guid.NewGuid()) // different org + .Without(r => r.ReportFile) + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(fixture.Create()); + + sutProvider.GetDependency() + .GetByIdAsync(request.ReportId) + .Returns(existingReport); + + var exception = await Assert.ThrowsAsync( + async () => await sutProvider.Sut.UpdateAsync(request)); + Assert.Equal("Organization report does not belong to the specified organization", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateAsync_EmptyOrganizationId_ThrowsBadRequestException( + SutProvider sutProvider) + { + var request = new UpdateOrganizationReportV2Request + { + ReportId = Guid.NewGuid(), + OrganizationId = Guid.Empty + }; + + var exception = await Assert.ThrowsAsync( + async () => await sutProvider.Sut.UpdateAsync(request)); + Assert.Equal("OrganizationId is required", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateAsync_EmptyReportId_ThrowsBadRequestException( + SutProvider sutProvider) + { + var request = new UpdateOrganizationReportV2Request + { + ReportId = Guid.Empty, + OrganizationId = Guid.NewGuid() + }; + + var exception = await Assert.ThrowsAsync( + async () => await sutProvider.Sut.UpdateAsync(request)); + Assert.Equal("ReportId is required", exception.Message); + } +} diff --git a/test/Core.Test/Dirt/ReportFeatures/ValidateOrganizationReportFileCommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/ValidateOrganizationReportFileCommandTests.cs new file mode 100644 index 000000000000..68691e5ef120 --- /dev/null +++ b/test/Core.Test/Dirt/ReportFeatures/ValidateOrganizationReportFileCommandTests.cs @@ -0,0 +1,154 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures; +using Bit.Core.Dirt.Reports.Services; +using Bit.Core.Dirt.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Dirt.ReportFeatures; + +[SutProviderCustomize] +public class ValidateOrganizationReportFileCommandTests +{ + private static OrganizationReport CreateReportWithFileData(Guid reportId, Guid organizationId, string fileId) + { + var fileData = new ReportFile + { + Id = fileId, + FileName = "report-data.json", + Validated = false + }; + + var report = new OrganizationReport + { + Id = reportId, + OrganizationId = organizationId, + RevisionDate = DateTime.UtcNow.AddDays(-1) + }; + report.SetReportFile(fileData); + return report; + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_ValidFile_SetsValidatedAndUpdatesReport( + SutProvider sutProvider) + { + // Arrange + var reportId = Guid.NewGuid(); + var organizationId = Guid.NewGuid(); + var fileId = "test-file-id-123"; + var report = CreateReportWithFileData(reportId, organizationId, fileId); + var originalRevisionDate = report.RevisionDate; + + sutProvider.GetDependency() + .ValidateFileAsync(report, Arg.Any(), 0, Core.Constants.FileSize501mb) + .Returns((true, 12345L)); + + // Act + var result = await sutProvider.Sut.ValidateAsync(report, fileId); + + // Assert + Assert.True(result); + + var fileData = report.GetReportFile(); + Assert.NotNull(fileData); + Assert.True(fileData!.Validated); + Assert.Equal(12345L, fileData.Size); + Assert.True(report.RevisionDate > originalRevisionDate); + + await sutProvider.GetDependency() + .Received(1) + .ReplaceAsync(report); + + await sutProvider.GetDependency() + .DidNotReceive() + .DeleteReportFilesAsync(Arg.Any(), Arg.Any()); + + await sutProvider.GetDependency() + .DidNotReceive() + .DeleteAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_InvalidFile_DeletesBlobAndReport( + SutProvider sutProvider) + { + // Arrange + var reportId = Guid.NewGuid(); + var organizationId = Guid.NewGuid(); + var fileId = "test-file-id-456"; + var report = CreateReportWithFileData(reportId, organizationId, fileId); + + sutProvider.GetDependency() + .ValidateFileAsync(report, Arg.Any(), 0, Core.Constants.FileSize501mb) + .Returns((false, -1L)); + + // Act + var result = await sutProvider.Sut.ValidateAsync(report, fileId); + + // Assert + Assert.False(result); + + await sutProvider.GetDependency() + .Received(1) + .DeleteReportFilesAsync(report, fileId); + + await sutProvider.GetDependency() + .Received(1) + .DeleteAsync(report); + + await sutProvider.GetDependency() + .DidNotReceive() + .ReplaceAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_NullFileData_ReturnsFalse( + SutProvider sutProvider) + { + // Arrange + var report = new OrganizationReport + { + Id = Guid.NewGuid(), + OrganizationId = Guid.NewGuid(), + ReportData = string.Empty + }; + + // Act + var result = await sutProvider.Sut.ValidateAsync(report, "any-file-id"); + + // Assert + Assert.False(result); + + await sutProvider.GetDependency() + .DidNotReceive() + .ValidateFileAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_MismatchedFileId_ReturnsFalse( + SutProvider sutProvider) + { + // Arrange + var reportId = Guid.NewGuid(); + var organizationId = Guid.NewGuid(); + var report = CreateReportWithFileData(reportId, organizationId, "stored-file-id"); + + // Act + var result = await sutProvider.Sut.ValidateAsync(report, "different-file-id"); + + // Assert + Assert.False(result); + + await sutProvider.GetDependency() + .DidNotReceive() + .ValidateFileAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } +} diff --git a/test/Core.Test/Dirt/Reports/Services/AzureOrganizationReportStorageServiceTests.cs b/test/Core.Test/Dirt/Reports/Services/AzureOrganizationReportStorageServiceTests.cs new file mode 100644 index 000000000000..05cad82130a3 --- /dev/null +++ b/test/Core.Test/Dirt/Reports/Services/AzureOrganizationReportStorageServiceTests.cs @@ -0,0 +1,142 @@ +using AutoFixture; +using Azure.Storage.Blobs; +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Models.Data; +using Bit.Core.Dirt.Reports.Services; +using Bit.Core.Enums; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Dirt.Reports.Services; + +public class AzureOrganizationReportStorageServiceTests +{ + private const string DevConnectionString = "UseDevelopmentStorage=true"; + + private static AzureOrganizationReportStorageService CreateSut() + { + var blobServiceClient = new BlobServiceClient(DevConnectionString); + var containerClient = blobServiceClient.GetBlobContainerClient( + AzureOrganizationReportStorageService.ContainerName); + var logger = Substitute.For>(); + return new AzureOrganizationReportStorageService(containerClient, logger); + } + + private static ReportFile CreateFileData(string fileId = "test-file-id-123") + { + return new ReportFile + { + Id = fileId, + FileName = "report-data.json", + Validated = false + }; + } + + [Fact] + public void FileUploadType_ReturnsAzure() + { + Assert.Equal(FileUploadType.Azure, CreateSut().FileUploadType); + } + + [Fact] + public async Task GetReportFileUploadUrlAsync_ReturnsValidSasUrl() + { + // Arrange + var fixture = new Fixture(); + var sut = CreateSut(); + + var report = fixture.Build() + .With(r => r.OrganizationId, Guid.NewGuid()) + .With(r => r.Id, Guid.NewGuid()) + .With(r => r.CreationDate, new DateTime(2026, 2, 17)) + .With(r => r.ReportData, string.Empty) + .Create(); + + var fileData = CreateFileData(); + + // Act + var url = await sut.GetReportFileUploadUrlAsync(report, fileData); + + // Assert + Assert.NotNull(url); + Assert.NotEmpty(url); + Assert.Contains("report-data.json", url); + Assert.Contains("sig=", url); + Assert.Contains("se=", url); + // Upload URL should have create and write permissions + var uri = new Uri(url); + var query = System.Web.HttpUtility.ParseQueryString(uri.Query); + var permissions = query["sp"]; + Assert.NotNull(permissions); + Assert.Contains("c", permissions); // Create + Assert.Contains("w", permissions); // Write + } + + [Fact] + public async Task GetReportDataDownloadUrlAsync_ReturnsValidSasUrl() + { + // Arrange + var fixture = new Fixture(); + var sut = CreateSut(); + + var report = fixture.Build() + .With(r => r.OrganizationId, Guid.NewGuid()) + .With(r => r.Id, Guid.NewGuid()) + .With(r => r.CreationDate, new DateTime(2026, 2, 17)) + .With(r => r.ReportData, string.Empty) + .Create(); + + var fileData = CreateFileData(); + + // Act + var url = await sut.GetReportDataDownloadUrlAsync(report, fileData); + + // Assert + Assert.NotNull(url); + Assert.NotEmpty(url); + Assert.Contains("report-data.json", url); + Assert.Contains("sig=", url); + // Download URL should have read-only permission + var uri = new Uri(url); + var query = System.Web.HttpUtility.ParseQueryString(uri.Query); + var permissions = query["sp"]; + Assert.NotNull(permissions); + Assert.Contains("r", permissions); // Read + Assert.DoesNotContain("w", permissions); // No write + } + + [Theory] + [InlineData("orgId/03-02-2026/reportId/fileId/report-data.json", "reportId")] + [InlineData("abc/01-01-2026/def/ghi/report-data.json", "def")] + public void ReportIdFromBlobName_ExtractsReportId(string blobName, string expectedReportId) + { + var result = AzureOrganizationReportStorageService.ReportIdFromBlobName(blobName); + Assert.Equal(expectedReportId, result); + } + + [Fact] + public void BlobPath_FormatsCorrectly() + { + // Arrange + var fixture = new Fixture(); + + var orgId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + var reportId = Guid.Parse("22222222-2222-2222-2222-222222222222"); + var creationDate = new DateTime(2026, 2, 17); + + var report = fixture.Build() + .With(r => r.OrganizationId, orgId) + .With(r => r.Id, reportId) + .With(r => r.CreationDate, creationDate) + .With(r => r.ReportData, string.Empty) + .Create(); + + // Act + var path = AzureOrganizationReportStorageService.BlobPath(report, "abc123xyz", "report-data.json"); + + // Assert + var expectedPath = $"{orgId}/02-17-2026/{reportId}/abc123xyz/report-data.json"; + Assert.Equal(expectedPath, path); + } +} diff --git a/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs b/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs new file mode 100644 index 000000000000..81a36e4055bf --- /dev/null +++ b/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs @@ -0,0 +1,272 @@ +using AutoFixture; +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Models.Data; +using Bit.Core.Dirt.Reports.Services; +using Bit.Core.Enums; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.Dirt.Reports.Services; + +[SutProviderCustomize] +public class LocalOrganizationReportStorageServiceTests +{ + private static Core.Settings.GlobalSettings GetGlobalSettings() + { + var globalSettings = new Core.Settings.GlobalSettings(); + globalSettings.OrganizationReport.BaseDirectory = "/tmp/bitwarden-test/reports"; + globalSettings.OrganizationReport.BaseUrl = "https://localhost/reports"; + return globalSettings; + } + + private static ReportFile CreateFileData(string fileId = "test-file-id") + { + return new ReportFile + { + Id = fileId, + FileName = "report-data.json", + Validated = false + }; + } + + [Fact] + public void FileUploadType_ReturnsDirect() + { + // Arrange + var globalSettings = GetGlobalSettings(); + var sut = new LocalOrganizationReportStorageService(globalSettings); + + // Act & Assert + Assert.Equal(FileUploadType.Direct, sut.FileUploadType); + } + + [Fact] + public async Task GetReportFileUploadUrlAsync_ReturnsApiEndpoint() + { + // Arrange + var fixture = new Fixture(); + var globalSettings = GetGlobalSettings(); + var sut = new LocalOrganizationReportStorageService(globalSettings); + + var orgId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + var report = fixture.Build() + .With(r => r.OrganizationId, orgId) + .With(r => r.Id, reportId) + .With(r => r.ReportData, string.Empty) + .Create(); + + var fileData = CreateFileData(); + + // Act + var url = await sut.GetReportFileUploadUrlAsync(report, fileData); + + // Assert + Assert.Equal($"/reports/organizations/{orgId}/{reportId}/file/report-data", url); + } + + [Fact] + public async Task GetReportDataDownloadUrlAsync_ReturnsBaseUrlWithPath() + { + // Arrange + var fixture = new Fixture(); + var globalSettings = GetGlobalSettings(); + var sut = new LocalOrganizationReportStorageService(globalSettings); + + var orgId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + var reportId = Guid.Parse("22222222-2222-2222-2222-222222222222"); + var creationDate = new DateTime(2026, 2, 17); + var fileData = CreateFileData("abc123"); + + var report = fixture.Build() + .With(r => r.OrganizationId, orgId) + .With(r => r.Id, reportId) + .With(r => r.CreationDate, creationDate) + .With(r => r.ReportData, string.Empty) + .Create(); + + // Act + var url = await sut.GetReportDataDownloadUrlAsync(report, fileData); + + // Assert + Assert.StartsWith("https://localhost/reports/", url); + Assert.Contains($"{orgId}", url); + Assert.Contains("02-17-2026", url); // Date format + Assert.Contains($"{reportId}", url); + Assert.Contains(fileData.Id, url); + Assert.EndsWith("report-data.json", url); + } + + [Theory] + [InlineData("../../../../etc/malicious")] + [InlineData("../../../../../tmp/evil")] + public async Task UploadReportDataAsync_WithPathTraversalPayload_ThrowsInvalidOperationException(string maliciousFileId) + { + // Arrange + var fixture = new Fixture(); + var tempDir = Path.Combine(Path.GetTempPath(), "bitwarden-test-" + Guid.NewGuid()); + + var globalSettings = new Core.Settings.GlobalSettings(); + globalSettings.OrganizationReport.BaseDirectory = tempDir; + globalSettings.OrganizationReport.BaseUrl = "https://localhost/reports"; + + var sut = new LocalOrganizationReportStorageService(globalSettings); + + var report = fixture.Build() + .With(r => r.OrganizationId, Guid.NewGuid()) + .With(r => r.Id, Guid.NewGuid()) + .With(r => r.CreationDate, DateTime.UtcNow) + .With(r => r.ReportData, string.Empty) + .Create(); + + var maliciousFileData = new ReportFile + { + Id = maliciousFileId, + FileName = "report-data.json", + Validated = false + }; + + var testData = "malicious content"; + var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(testData)); + + try + { + // Act & Assert - EnsurePathWithinBaseDir guard rejects the traversal attempt + await Assert.ThrowsAsync( + () => sut.UploadReportDataAsync(report, maliciousFileData, stream)); + } + finally + { + // Cleanup + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + [Fact] + public async Task UploadReportDataAsync_CreatesDirectoryAndWritesFile() + { + // Arrange + var fixture = new Fixture(); + var tempDir = Path.Combine(Path.GetTempPath(), "bitwarden-test-" + Guid.NewGuid()); + + var globalSettings = new Core.Settings.GlobalSettings(); + globalSettings.OrganizationReport.BaseDirectory = tempDir; + globalSettings.OrganizationReport.BaseUrl = "https://localhost/reports"; + + var sut = new LocalOrganizationReportStorageService(globalSettings); + + var report = fixture.Build() + .With(r => r.OrganizationId, Guid.NewGuid()) + .With(r => r.Id, Guid.NewGuid()) + .With(r => r.CreationDate, DateTime.UtcNow) + .With(r => r.ReportData, string.Empty) + .Create(); + + var fileData = CreateFileData("test-file-123"); + var testData = "test report data content"; + var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(testData)); + + try + { + // Act + await sut.UploadReportDataAsync(report, fileData, stream); + + // Assert + var expectedDir = Path.Combine(tempDir, report.OrganizationId.ToString(), + report.CreationDate.ToString("MM-dd-yyyy"), report.Id.ToString(), fileData.Id); + Assert.True(Directory.Exists(expectedDir)); + + var expectedFile = Path.Combine(expectedDir, "report-data.json"); + Assert.True(File.Exists(expectedFile)); + + var fileContent = await File.ReadAllTextAsync(expectedFile); + Assert.Equal(testData, fileContent); + } + finally + { + // Cleanup + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + [Fact] + public async Task ValidateFileAsync_FileExists_ReturnsValidAndLength() + { + // Arrange + var fixture = new Fixture(); + var tempDir = Path.Combine(Path.GetTempPath(), "bitwarden-test-" + Guid.NewGuid()); + + var globalSettings = new Core.Settings.GlobalSettings(); + globalSettings.OrganizationReport.BaseDirectory = tempDir; + globalSettings.OrganizationReport.BaseUrl = "https://localhost/reports"; + + var sut = new LocalOrganizationReportStorageService(globalSettings); + + var report = fixture.Build() + .With(r => r.OrganizationId, Guid.NewGuid()) + .With(r => r.Id, Guid.NewGuid()) + .With(r => r.CreationDate, DateTime.UtcNow) + .With(r => r.ReportData, string.Empty) + .Create(); + + var fileData = CreateFileData("validate-test-file"); + var testData = "test content for validation"; + var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(testData)); + + try + { + // First upload a file + await sut.UploadReportDataAsync(report, fileData, stream); + + // Act + var (valid, length) = await sut.ValidateFileAsync(report, fileData, 0, 1000); + + // Assert + Assert.True(valid); + Assert.Equal(testData.Length, length); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + [Fact] + public async Task ValidateFileAsync_FileDoesNotExist_ReturnsInvalid() + { + // Arrange + var fixture = new Fixture(); + var tempDir = Path.Combine(Path.GetTempPath(), "bitwarden-test-" + Guid.NewGuid()); + + var globalSettings = new Core.Settings.GlobalSettings(); + globalSettings.OrganizationReport.BaseDirectory = tempDir; + globalSettings.OrganizationReport.BaseUrl = "https://localhost/reports"; + + var sut = new LocalOrganizationReportStorageService(globalSettings); + + var report = fixture.Build() + .With(r => r.OrganizationId, Guid.NewGuid()) + .With(r => r.Id, Guid.NewGuid()) + .With(r => r.CreationDate, DateTime.UtcNow) + .With(r => r.ReportData, string.Empty) + .Create(); + + var fileData = CreateFileData("nonexistent-file"); + + // Act + var (valid, length) = await sut.ValidateFileAsync(report, fileData, 0, 1000); + + // Assert + Assert.False(valid); + Assert.Equal(-1, length); + } +}