diff --git a/src/Api/Dirt/Controllers/OrganizationReportsController.cs b/src/Api/Dirt/Controllers/OrganizationReportsController.cs index 0d60587d2cc5..12cda1fc747d 100644 --- a/src/Api/Dirt/Controllers/OrganizationReportsController.cs +++ b/src/Api/Dirt/Controllers/OrganizationReportsController.cs @@ -1,11 +1,21 @@ -using Bit.Api.Dirt.Models.Response; +using System.Text.Json; +using Bit.Api.Dirt.Models.Request; +using Bit.Api.Dirt.Models.Response; +using Bit.Api.Utilities; +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.Dirt.Repositories; using Bit.Core.Exceptions; +using Bit.Core.Services; +using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using ZiggyCreatures.Caching.Fusion; namespace Bit.Api.Dirt.Controllers; @@ -20,10 +30,17 @@ public class OrganizationReportsController : Controller private readonly IUpdateOrganizationReportSummaryCommand _updateOrganizationReportSummaryCommand; private readonly IGetOrganizationReportSummaryDataQuery _getOrganizationReportSummaryDataQuery; private readonly IGetOrganizationReportSummaryDataByDateRangeQuery _getOrganizationReportSummaryDataByDateRangeQuery; - private readonly IGetOrganizationReportDataQuery _getOrganizationReportDataQuery; - 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; + private readonly IFusionCache _cache; public OrganizationReportsController( ICurrentContext currentContext, @@ -33,11 +50,17 @@ public OrganizationReportsController( IUpdateOrganizationReportSummaryCommand updateOrganizationReportSummaryCommand, IGetOrganizationReportSummaryDataQuery getOrganizationReportSummaryDataQuery, IGetOrganizationReportSummaryDataByDateRangeQuery getOrganizationReportSummaryDataByDateRangeQuery, - 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, + [FromKeyedServices(OrganizationReportCacheConstants.CacheName)] IFusionCache cache) { _currentContext = currentContext; _getOrganizationReportQuery = getOrganizationReportQuery; @@ -46,91 +69,173 @@ IUpdateOrganizationReportApplicationDataCommand updateOrganizationReportApplicat _updateOrganizationReportSummaryCommand = updateOrganizationReportSummaryCommand; _getOrganizationReportSummaryDataQuery = getOrganizationReportSummaryDataQuery; _getOrganizationReportSummaryDataByDateRangeQuery = getOrganizationReportSummaryDataByDateRangeQuery; - _getOrganizationReportDataQuery = getOrganizationReportDataQuery; - _updateOrganizationReportDataCommand = updateOrganizationReportDataCommand; _getOrganizationReportApplicationDataQuery = getOrganizationReportApplicationDataQuery; _updateOrganizationReportApplicationDataCommand = updateOrganizationReportApplicationDataCommand; + _featureService = featureService; + _applicationCacheService = applicationCacheService; + _storageService = storageService; + _createReportCommand = createReportCommand; + _organizationReportRepo = organizationReportRepo; + _updateReportV2Command = updateReportV2Command; + _validateCommand = validateCommand; + _logger = logger; + _cache = cache; } - #region Whole OrganizationReport Endpoints - - [HttpGet("{organizationId}/latest")] - public async Task GetLatestOrganizationReportAsync(Guid organizationId) + /// + /// Creates a new organization report for the specified organization. + /// When the Access Intelligence V2 feature flag is enabled, validates the file size and returns + /// a presigned upload URL for the report file along with the created report metadata. + /// Otherwise, creates the report with inline data. + /// + /// The unique identifier of the organization. + /// The request model containing report data and optional file metadata. + /// An with upload URL when V2 is enabled, + /// or an otherwise. + [HttpPost("{organizationId}")] + [RequestSizeLimit(Constants.FileSize501mb)] + public async Task CreateOrganizationReportAsync( + Guid organizationId, + [FromBody] AddOrganizationReportRequestModel request) { - if (!await _currentContext.AccessReports(organizationId)) + EnsureValidIds(organizationId); + + await AuthorizeAsync(organizationId); + + if (_featureService.IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2)) { - throw new NotFoundException(); - } + if (!request.FileSize.HasValue) + { + throw new BadRequestException("File size is required."); + } - var latestReport = await _getOrganizationReportQuery.GetLatestOrganizationReportAsync(organizationId); - var response = latestReport == null ? null : new OrganizationReportResponseModel(latestReport); + if (request.FileSize.Value > Constants.FileSize501mb) + { + throw new BadRequestException("Max file size is 500 MB."); + } + + var report = await _createReportCommand.CreateAsync(request.ToData(organizationId)); + var fileData = report.GetReportFile()!; + var reportFileUploadUrl = await _storageService.GetReportFileUploadUrlAsync(report, fileData); + return Ok(new OrganizationReportFileResponseModel + { + ReportFileUploadUrl = reportFileUploadUrl, + ReportResponse = new OrganizationReportResponseModel(report), + FileUploadType = _storageService.FileUploadType + }); + } + + var v1Report = await _addOrganizationReportCommand.AddOrganizationReportAsync(request.ToData(organizationId)); + var response = v1Report == null ? null : new OrganizationReportResponseModel(v1Report); return Ok(response); } - [HttpGet("{organizationId}/{reportId}")] - public async Task GetOrganizationReportAsync(Guid organizationId, Guid reportId) + + /// + /// Gets the most recent organization report for the specified organization. + /// Includes a presigned download URL for the report file if one has been validated. + /// + /// The unique identifier of the organization. + /// An for the most recent report. + [HttpGet("{organizationId}/latest")] + public async Task GetLatestOrganizationReportAsync(Guid organizationId) { - if (!await _currentContext.AccessReports(organizationId)) - { - throw new NotFoundException(); - } + EnsureValidIds(organizationId); - var report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); + await AuthorizeAsync(organizationId); + + var latestReport = await _getOrganizationReportQuery.GetLatestOrganizationReportAsync(organizationId); - if (report == null) + if (latestReport == null) { - throw new NotFoundException("Report not found for the specified organization."); + throw new NotFoundException(); } - if (report.OrganizationId != organizationId) + var response = new OrganizationReportResponseModel(latestReport); + + if (_featureService.IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2)) { - throw new BadRequestException("Invalid report ID"); + var fileData = latestReport.GetReportFile(); + if (fileData is { Validated: true }) + { + response.ReportFileDownloadUrl = await _storageService.GetReportDataDownloadUrlAsync(latestReport, fileData); + } } - return Ok(report); + return Ok(response); } - [HttpPost("{organizationId}")] - public async Task CreateOrganizationReportAsync(Guid organizationId, [FromBody] AddOrganizationReportRequest request) + /// + /// Gets a specific organization report by its report ID. + /// Validates that the report belongs to the specified organization. + /// Includes a presigned download URL for the report file if one has been validated. + /// + /// The unique identifier of the organization. + /// The unique identifier of the report to retrieve. + /// An matching the specified IDs. + [HttpGet("{organizationId}/{reportId}")] + public async Task GetOrganizationReportAsync(Guid organizationId, Guid reportId) { - if (!await _currentContext.AccessReports(organizationId)) + var report = await GetAuthorizedReportAsync(organizationId, reportId); + + var response = new OrganizationReportResponseModel(report); + + var fileData = report.GetReportFile(); + if (fileData == null) { - throw new NotFoundException(); + return Ok(response); } - if (request.OrganizationId != organizationId) + if (fileData.Validated) { - throw new BadRequestException("Organization ID in the request body must match the route parameter"); + response.ReportFileDownloadUrl = await _storageService.GetReportDataDownloadUrlAsync(report, fileData); } - var report = await _addOrganizationReportCommand.AddOrganizationReportAsync(request); - var response = report == null ? null : new OrganizationReportResponseModel(report); return Ok(response); } + /// + /// Updates an existing organization report's metadata for the specified organization. + /// Updates fields such as summary data, application data, metrics, and content encryption key. + /// To create a new report with a file upload, use the POST endpoint instead. + /// + /// The unique identifier of the organization. + /// The unique identifier of the report to update. + /// The request model containing updated report data. + /// An with the updated report. [HttpPatch("{organizationId}/{reportId}")] - public async Task UpdateOrganizationReportAsync(Guid organizationId, [FromBody] UpdateOrganizationReportRequest request) + public async Task UpdateOrganizationReportAsync( + Guid organizationId, + Guid reportId, + [FromBody] UpdateOrganizationReportV2RequestModel request) { - if (!await _currentContext.AccessReports(organizationId)) - { - throw new NotFoundException(); - } + EnsureValidIds(organizationId, reportId); + + await AuthorizeAsync(organizationId); - if (request.OrganizationId != organizationId) + if (_featureService.IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2)) { - throw new BadRequestException("Organization ID in the request body must match the route parameter"); + var coreRequest = request.ToData(organizationId, reportId); + var report = await _updateReportV2Command.UpdateAsync(coreRequest); + return Ok(new OrganizationReportResponseModel(report)); } - 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. /// Returns all report summary entries within the range. @@ -148,168 +253,336 @@ public async Task UpdateOrganizationReportAsync(Guid organization public async Task GetOrganizationReportSummaryDataByDateRangeAsync( Guid organizationId, [FromQuery] DateTime startDate, [FromQuery] DateTime endDate) { - if (!await _currentContext.AccessReports(organizationId)) - { - throw new NotFoundException(); - } + EnsureValidIds(organizationId); - if (organizationId == Guid.Empty) - { - throw new BadRequestException("Organization ID is required."); - } + await AuthorizeAsync(organizationId); var summaryDataList = await _getOrganizationReportSummaryDataByDateRangeQuery .GetOrganizationReportSummaryDataByDateRangeAsync(organizationId, startDate, endDate); - return Ok(summaryDataList); + return Ok(summaryDataList.Select(s => new OrganizationReportSummaryDataResponseModel(s))); } - [HttpGet("{organizationId}/data/summary/{reportId}")] - public async Task GetOrganizationReportSummaryAsync(Guid organizationId, Guid reportId) + /// + /// Deletes an organization report and its associated file from storage. + /// Removes the database record first, then cleans up any stored files. + /// + /// The unique identifier of the organization. + /// The unique identifier of the report to delete. + [HttpDelete("{organizationId}/{reportId}")] + public async Task DeleteOrganizationReportAsync(Guid organizationId, Guid reportId) { - if (!await _currentContext.AccessReports(organizationId)) + var report = await GetAuthorizedReportAsync(organizationId, reportId); + + var fileData = report.GetReportFile(); + + await _organizationReportRepo.DeleteAsync(report); + + await _cache.RemoveByTagAsync( + OrganizationReportCacheConstants.BuildCacheTagForOrganizationReports(organizationId)); + + if (fileData != null && !string.IsNullOrEmpty(fileData.Id)) { - throw new NotFoundException(); + try + { + await _storageService.DeleteReportFilesAsync(report, fileData.Id); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Failed to delete storage files for report {ReportId}, file {FileId}. Manual cleanup may be required.", + reportId, fileData.Id); + } } + } - var summaryData = - await _getOrganizationReportSummaryDataQuery.GetOrganizationReportSummaryDataAsync(organizationId, reportId); + /// + /// Renews the file upload URL for an organization report that has not yet been validated. + /// Returns a fresh presigned upload URL for the report file, allowing the client to retry + /// an upload after the original URL has expired. Requires the Access Intelligence V2 feature flag. + /// + /// The unique identifier of the organization. + /// The unique identifier of the report with the pending file upload. + /// The identifier of the report file entry to renew the upload URL for. + /// An with the renewed upload URL. + [RequireFeature(FeatureFlagKeys.AccessIntelligenceVersion2)] + [HttpGet("{organizationId}/{reportId}/file/renew")] + public async Task RenewFileUploadUrlAsync( + Guid organizationId, Guid reportId, [FromQuery] string reportFileId) + { + var report = await GetAuthorizedReportAsync(organizationId, reportId); - if (summaryData == null) + if (string.IsNullOrEmpty(reportFileId)) { - throw new NotFoundException("Report not found for the specified organization."); + throw new BadRequestException("ReportFileId is required."); + } + + var fileData = report.GetReportFile(); + if (fileData == null || fileData.Id != reportFileId || fileData.Validated) + { + throw new NotFoundException(); } - return Ok(summaryData); + return new OrganizationReportFileResponseModel + { + ReportFileUploadUrl = await _storageService.GetReportFileUploadUrlAsync(report, fileData), + ReportResponse = new OrganizationReportResponseModel(report), + FileUploadType = _storageService.FileUploadType + }; } - [HttpPatch("{organizationId}/data/summary/{reportId}")] - public async Task UpdateOrganizationReportSummaryAsync(Guid organizationId, Guid reportId, [FromBody] UpdateOrganizationReportSummaryRequest request) + /// + /// Handles Azure Event Grid webhook notifications for blob storage events. + /// When a Microsoft.Storage.BlobCreated event is received, validates the uploaded + /// report file against the corresponding organization report. Orphaned blobs (with no + /// matching report) are deleted. Requires the Access Intelligence V2 feature flag. + /// This endpoint is anonymous to allow Azure Event Grid to call it directly. + /// + /// An acknowledging the Event Grid event. + [AllowAnonymous] + [RequireFeature(FeatureFlagKeys.AccessIntelligenceVersion2)] + [HttpPost("file/validate/azure")] + public async Task AzureValidateFileAsync() { - if (!await _currentContext.AccessReports(organizationId)) + return await ApiHelpers.HandleAzureEvents(Request, new Dictionary> { - throw new NotFoundException(); + { + "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)); + } + } + } + }); + } + + /// + /// Uploads a report data file for a self-hosted organization report via multipart form data. + /// Validates the uploaded file size against the expected size (with a 1 MB leeway) and marks + /// the report file as validated upon success. Requires the Access Intelligence V2 feature flag. + /// + /// The unique identifier of the organization. + /// The unique identifier of the report to attach the file to. + /// The identifier of the report file entry to upload against. + [RequireFeature(FeatureFlagKeys.AccessIntelligenceVersion2)] + [HttpPost("{organizationId}/{reportId}/file")] + [SelfHosted(SelfHostedOnly = true)] + [RequestSizeLimit(Constants.FileSize501mb)] + [DisableFormValueModelBinding] + public async Task UploadReportFileAsync(Guid organizationId, Guid reportId, [FromQuery] string reportFileId) + { + var report = await GetAuthorizedReportAsync(organizationId, reportId); + + if (!Request?.ContentType?.Contains("multipart/") ?? true) + { + throw new BadRequestException("Invalid content."); + } + + if (string.IsNullOrEmpty(reportFileId)) + { + throw new BadRequestException("ReportFileId query parameter is required"); } - if (request.OrganizationId != organizationId) + var fileData = report.GetReportFile(); + if (fileData == null || fileData.Id != reportFileId) { - throw new BadRequestException("Organization ID in the request body must match the route parameter"); + throw new NotFoundException(); } - if (request.ReportId != reportId) + await Request.GetFileAsync(async (stream) => { - throw new BadRequestException("Report ID in the request body must match the route parameter"); + 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) + { + await _storageService.DeleteReportFilesAsync(report, fileData.Id!); + await _organizationReportRepo.DeleteAsync(report); + await _cache.RemoveByTagAsync( + OrganizationReportCacheConstants.BuildCacheTagForOrganizationReports(organizationId)); + throw new BadRequestException("File received does not match expected constraints."); } - var updatedReport = await _updateOrganizationReportSummaryCommand.UpdateOrganizationReportSummaryAsync(request); - var response = new OrganizationReportResponseModel(updatedReport); - return Ok(response); + fileData.Validated = true; + fileData.Size = length; + report.SetReportFile(fileData); + report.RevisionDate = DateTime.UtcNow; + await _organizationReportRepo.ReplaceAsync(report); + await _cache.RemoveByTagAsync( + OrganizationReportCacheConstants.BuildCacheTagForOrganizationReports(organizationId)); } - #endregion - #region ReportData Field Endpoints - - [HttpGet("{organizationId}/data/report/{reportId}")] - public async Task GetOrganizationReportDataAsync(Guid organizationId, Guid reportId) + /// + /// Downloads an organization report file for a self-hosted instance. + /// Validates that the organization ID and report ID are non-empty, + /// then authorizes the caller via . + /// Verifies the report exists and belongs to the specified organization. + /// Retrieves the file metadata and streams the file from local storage. + /// Cloud-hosted instances download files directly from Azure Blob Storage + /// using presigned SAS URLs and never call this endpoint. + /// + /// The unique identifier of the organization. + /// The unique identifier of the report whose file to download. + /// A containing the report file with content type application/octet-stream. + [SelfHosted(SelfHostedOnly = true)] + [HttpGet("{organizationId}/{reportId}/file/download")] + public async Task DownloadReportFileAsync(Guid organizationId, Guid reportId) { - if (!await _currentContext.AccessReports(organizationId)) + var report = await GetAuthorizedReportAsync(organizationId, reportId); + + var fileData = report.GetReportFile(); + if (fileData == null) { throw new NotFoundException(); } - var reportData = await _getOrganizationReportDataQuery.GetOrganizationReportDataAsync(organizationId, reportId); - - if (reportData == null) + var stream = await _storageService.GetReportReadStreamAsync(report, fileData); + if (stream == null) { - throw new NotFoundException("Organization report data not found."); + throw new NotFoundException(); } - return Ok(reportData); + return File(stream, "application/octet-stream", fileData.FileName); } - [HttpPatch("{organizationId}/data/report/{reportId}")] - public async Task UpdateOrganizationReportDataAsync(Guid organizationId, Guid reportId, [FromBody] UpdateOrganizationReportDataRequest request) + private async Task AuthorizeAsync(Guid organizationId) { if (!await _currentContext.AccessReports(organizationId)) { throw new NotFoundException(); } - if (request.OrganizationId != organizationId) + var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId); + if (orgAbility is null || !orgAbility.UseRiskInsights) { - throw new BadRequestException("Organization ID in the request body must match the route parameter"); + throw new BadRequestException("Your organization's plan does not support this feature."); } + } - if (request.ReportId != reportId) + private static void EnsureValidIds(Guid organizationId, Guid? reportId = null) + { + if (organizationId == Guid.Empty) { - throw new BadRequestException("Report ID in the request body must match the route parameter"); + throw new BadRequestException("OrganizationId is required."); } - var updatedReport = await _updateOrganizationReportDataCommand.UpdateOrganizationReportDataAsync(request); - var response = new OrganizationReportResponseModel(updatedReport); + if (reportId.HasValue && reportId.Value == Guid.Empty) + { + throw new BadRequestException("ReportId is required."); + } + } - return Ok(response); + private async Task GetAuthorizedReportAsync(Guid organizationId, Guid reportId) + { + EnsureValidIds(organizationId, reportId); + await AuthorizeAsync(organizationId); + var report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); + if (report.OrganizationId != organizationId) throw new BadRequestException("Invalid report ID"); + return report; } - #endregion - #region ApplicationData Field Endpoints + // Is being used by client on V2 - [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(); - } + EnsureValidIds(organizationId, reportId); - var applicationData = await _getOrganizationReportApplicationDataQuery.GetOrganizationReportApplicationDataAsync(organizationId, reportId); + await AuthorizeAsync(organizationId); - if (applicationData == null) - { - throw new NotFoundException("Organization report application data not found."); - } + var summaryData = + await _getOrganizationReportSummaryDataQuery.GetOrganizationReportSummaryDataAsync(organizationId, reportId); - return Ok(applicationData); - } - catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + if (summaryData == null) { - throw; + throw new NotFoundException("Report not found for the specified organization."); } + + return Ok(new OrganizationReportSummaryDataResponseModel(summaryData)); } - [HttpPatch("{organizationId}/data/application/{reportId}")] - public async Task UpdateOrganizationReportApplicationDataAsync(Guid organizationId, Guid reportId, [FromBody] UpdateOrganizationReportApplicationDataRequest request) + [HttpPatch("{organizationId}/data/summary/{reportId}")] + public async Task UpdateOrganizationReportSummaryAsync( + Guid organizationId, + Guid reportId, + [FromBody] UpdateOrganizationReportSummaryRequestModel request) { - try - { - if (!await _currentContext.AccessReports(organizationId)) - { - throw new NotFoundException(); - } + EnsureValidIds(organizationId, reportId); - if (request.OrganizationId != organizationId) - { - throw new BadRequestException("Organization ID in the request body must match the route parameter"); - } + await AuthorizeAsync(organizationId); - if (request.Id != reportId) - { - throw new BadRequestException("Report ID in the request body must match the route parameter"); - } + var updatedReport = await _updateOrganizationReportSummaryCommand + .UpdateOrganizationReportSummaryAsync(request.ToData(organizationId, reportId)); + var response = new OrganizationReportResponseModel(updatedReport); - var updatedReport = await _updateOrganizationReportApplicationDataCommand.UpdateOrganizationReportApplicationDataAsync(request); - var response = new OrganizationReportResponseModel(updatedReport); + return Ok(response); + } - return Ok(response); - } - catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + [HttpGet("{organizationId}/data/application/{reportId}")] + public async Task GetOrganizationReportApplicationDataAsync(Guid organizationId, Guid reportId) + { + EnsureValidIds(organizationId, reportId); + + await AuthorizeAsync(organizationId); + + var applicationData = await _getOrganizationReportApplicationDataQuery + .GetOrganizationReportApplicationDataAsync(organizationId, reportId); + + if (applicationData == null) { - throw; + throw new NotFoundException("Organization report application data not found."); } + + return Ok(new OrganizationReportApplicationDataResponseModel(applicationData)); } - #endregion + + [HttpPatch("{organizationId}/data/application/{reportId}")] + public async Task UpdateOrganizationReportApplicationDataAsync( + Guid organizationId, + Guid reportId, + [FromBody] UpdateOrganizationReportApplicationDataRequestModel request) + { + EnsureValidIds(organizationId, reportId); + + await AuthorizeAsync(organizationId); + + var updatedReport = await _updateOrganizationReportApplicationDataCommand + .UpdateOrganizationReportApplicationDataAsync(request.ToData(organizationId, reportId)); + var response = new OrganizationReportResponseModel(updatedReport); + + return Ok(response); + } } diff --git a/src/Api/Dirt/Models/Request/AddOrganizationReportRequestModel.cs b/src/Api/Dirt/Models/Request/AddOrganizationReportRequestModel.cs new file mode 100644 index 000000000000..34b6e3202aae --- /dev/null +++ b/src/Api/Dirt/Models/Request/AddOrganizationReportRequestModel.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +namespace Bit.Api.Dirt.Models.Request; + +public class AddOrganizationReportRequestModel +{ + public string? ReportData { get; set; } + public string? ContentEncryptionKey { get; set; } + public string? SummaryData { get; set; } + public string? ApplicationData { get; set; } + [JsonPropertyName("metrics")] + public OrganizationReportMetrics? ReportMetrics { get; set; } + public long? FileSize { get; set; } + + public AddOrganizationReportRequest ToData(Guid organizationId) + { + return new AddOrganizationReportRequest + { + OrganizationId = organizationId, + ReportData = ReportData, + ContentEncryptionKey = ContentEncryptionKey, + SummaryData = SummaryData, + ApplicationData = ApplicationData, + ReportMetrics = ReportMetrics, + FileSize = FileSize + }; + } +} diff --git a/src/Api/Dirt/Models/Request/UpdateOrganizationReportApplicationDataRequestModel.cs b/src/Api/Dirt/Models/Request/UpdateOrganizationReportApplicationDataRequestModel.cs new file mode 100644 index 000000000000..9461dc68d9ff --- /dev/null +++ b/src/Api/Dirt/Models/Request/UpdateOrganizationReportApplicationDataRequestModel.cs @@ -0,0 +1,18 @@ +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +namespace Bit.Api.Dirt.Models.Request; + +public class UpdateOrganizationReportApplicationDataRequestModel +{ + public string? ApplicationData { get; set; } + + public UpdateOrganizationReportApplicationDataRequest ToData(Guid organizationId, Guid reportId) + { + return new UpdateOrganizationReportApplicationDataRequest + { + OrganizationId = organizationId, + Id = reportId, + ApplicationData = ApplicationData + }; + } +} diff --git a/src/Api/Dirt/Models/Request/UpdateOrganizationReportSummaryRequestModel.cs b/src/Api/Dirt/Models/Request/UpdateOrganizationReportSummaryRequestModel.cs new file mode 100644 index 000000000000..15c7f91eb823 --- /dev/null +++ b/src/Api/Dirt/Models/Request/UpdateOrganizationReportSummaryRequestModel.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +namespace Bit.Api.Dirt.Models.Request; + +public class UpdateOrganizationReportSummaryRequestModel +{ + public string? SummaryData { get; set; } + [JsonPropertyName("metrics")] + public OrganizationReportMetrics? ReportMetrics { get; set; } + + public UpdateOrganizationReportSummaryRequest ToData(Guid organizationId, Guid reportId) + { + return new UpdateOrganizationReportSummaryRequest + { + OrganizationId = organizationId, + ReportId = reportId, + SummaryData = SummaryData, + ReportMetrics = ReportMetrics + }; + } +} diff --git a/src/Api/Dirt/Models/Request/UpdateOrganizationReportV2RequestModel.cs b/src/Api/Dirt/Models/Request/UpdateOrganizationReportV2RequestModel.cs new file mode 100644 index 000000000000..db455a245caa --- /dev/null +++ b/src/Api/Dirt/Models/Request/UpdateOrganizationReportV2RequestModel.cs @@ -0,0 +1,26 @@ +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +namespace Bit.Api.Dirt.Models.Request; + +public class UpdateOrganizationReportV2RequestModel +{ + 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 UpdateOrganizationReportV2Request ToData(Guid organizationId, Guid reportId) + { + return new UpdateOrganizationReportV2Request + { + OrganizationId = organizationId, + ReportId = reportId, + ReportData = ReportData, + ContentEncryptionKey = ContentEncryptionKey, + SummaryData = SummaryData, + ApplicationData = ApplicationData, + ReportMetrics = ReportMetrics + }; + } +} diff --git a/src/Api/Dirt/Models/Response/OrganizationReportApplicationDataResponseModel.cs b/src/Api/Dirt/Models/Response/OrganizationReportApplicationDataResponseModel.cs new file mode 100644 index 000000000000..dd43cfba994f --- /dev/null +++ b/src/Api/Dirt/Models/Response/OrganizationReportApplicationDataResponseModel.cs @@ -0,0 +1,13 @@ +using Bit.Core.Dirt.Models.Data; + +namespace Bit.Api.Dirt.Models.Response; + +public class OrganizationReportApplicationDataResponseModel +{ + public OrganizationReportApplicationDataResponseModel(OrganizationReportApplicationDataResponse applicationDataResponse) + { + ApplicationData = applicationDataResponse.ApplicationData; + } + + public string? ApplicationData { get; set; } +} 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/Api/Dirt/Models/Response/OrganizationReportSummaryDataResponseModel.cs b/src/Api/Dirt/Models/Response/OrganizationReportSummaryDataResponseModel.cs new file mode 100644 index 000000000000..f9fdd127b76e --- /dev/null +++ b/src/Api/Dirt/Models/Response/OrganizationReportSummaryDataResponseModel.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; +using Bit.Core.Dirt.Models.Data; + +namespace Bit.Api.Dirt.Models.Response; + +public class OrganizationReportSummaryDataResponseModel +{ + public OrganizationReportSummaryDataResponseModel(OrganizationReportSummaryDataResponse summaryDataResponse) + { + EncryptedData = summaryDataResponse.SummaryData; + EncryptionKey = summaryDataResponse.ContentEncryptionKey; + Date = summaryDataResponse.RevisionDate; + } + + [JsonPropertyName("encryptedData")] + public string EncryptedData { get; set; } + + [JsonPropertyName("encryptionKey")] + public string EncryptionKey { get; set; } + + [JsonPropertyName("date")] + public DateTime Date { get; set; } +} diff --git a/src/Api/Dirt/Models/Response/OrganizationReportSummaryModel.cs b/src/Api/Dirt/Models/Response/OrganizationReportSummaryModel.cs deleted file mode 100644 index d912fb699e88..000000000000 --- a/src/Api/Dirt/Models/Response/OrganizationReportSummaryModel.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Bit.Api.Dirt.Models.Response; - -public class OrganizationReportSummaryModel -{ - public Guid OrganizationId { get; set; } - public required string EncryptedData { get; set; } - public required string EncryptionKey { get; set; } - public DateTime Date { get; set; } -} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index df3507e1496e..8bf500268970 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -292,6 +292,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 EventManagementForBlumira = "event-management-for-blumira"; public const string EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike"; public const string EventDiagnosticLogging = "pm-27666-siem-event-log-debugging"; 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/OrganizationReportDataResponse.cs b/src/Core/Dirt/Models/Data/OrganizationReportDataResponse.cs deleted file mode 100644 index c284d99ff28a..000000000000 --- a/src/Core/Dirt/Models/Data/OrganizationReportDataResponse.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Bit.Core.Dirt.Models.Data; - -public class OrganizationReportDataResponse -{ - public string? ReportData { get; set; } -} 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..49b99b3cb083 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/CreateOrganizationReportCommand.cs @@ -0,0 +1,121 @@ +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.DependencyInjection; +using Microsoft.Extensions.Logging; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class CreateOrganizationReportCommand : ICreateOrganizationReportCommand +{ + private readonly IOrganizationRepository _organizationRepo; + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly ILogger _logger; + private readonly IFusionCache _cache; + + public CreateOrganizationReportCommand( + IOrganizationRepository organizationRepository, + IOrganizationReportRepository organizationReportRepository, + ILogger logger, + [FromKeyedServices(OrganizationReportCacheConstants.CacheName)] IFusionCache cache) + { + _organizationRepo = organizationRepository; + _organizationReportRepo = organizationReportRepository; + _logger = logger; + _cache = cache; + } + + 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); + + await _cache.RemoveByTagAsync(OrganizationReportCacheConstants.BuildCacheTagForOrganizationReports(request.OrganizationId)); + + _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"); + } + + if (string.IsNullOrWhiteSpace(request.SummaryData)) + { + return (false, "Summary Data is required"); + } + + if (string.IsNullOrWhiteSpace(request.ApplicationData)) + { + return (false, "Application Data is required"); + } + + if (request.ReportMetrics == null) + { + return (false, "Report Metrics 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..437272726f95 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportApplicationDataQuery.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportApplicationDataQuery.cs @@ -21,42 +21,29 @@ 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) { - _logger.LogError(ex, "Error fetching organization report application data for organization {organizationId} and report {reportId}", - organizationId, reportId); - throw; + throw new BadRequestException("ReportId is required."); } + + var report = await _organizationReportRepo.GetByIdAsync(reportId); + if (report == null || report.OrganizationId != organizationId) + { + throw new NotFoundException("Organization report application data not found."); + } + + var applicationDataResponse = await _organizationReportRepo.GetApplicationDataAsync(reportId); + + if (applicationDataResponse == null) + { + throw new NotFoundException("Organization report application data not found."); + } + + return applicationDataResponse; } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportDataQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportDataQuery.cs deleted file mode 100644 index d53fa56111c4..000000000000 --- a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportDataQuery.cs +++ /dev/null @@ -1,62 +0,0 @@ -using Bit.Core.Dirt.Models.Data; -using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; -using Bit.Core.Dirt.Repositories; -using Bit.Core.Exceptions; -using Microsoft.Extensions.Logging; - -namespace Bit.Core.Dirt.Reports.ReportFeatures; - -public class GetOrganizationReportDataQuery : IGetOrganizationReportDataQuery -{ - private readonly IOrganizationReportRepository _organizationReportRepo; - private readonly ILogger _logger; - - public GetOrganizationReportDataQuery( - IOrganizationReportRepository organizationReportRepo, - ILogger logger) - { - _organizationReportRepo = organizationReportRepo; - _logger = logger; - } - - public async Task GetOrganizationReportDataAsync(Guid organizationId, Guid reportId) - { - try - { - _logger.LogInformation(Constants.BypassFiltersEventId, "Fetching organization report data for organization {organizationId} and report {reportId}", - organizationId, reportId); - - if (organizationId == Guid.Empty) - { - _logger.LogWarning(Constants.BypassFiltersEventId, "GetOrganizationReportDataAsync called with empty OrganizationId"); - throw new BadRequestException("OrganizationId is required."); - } - - if (reportId == Guid.Empty) - { - _logger.LogWarning(Constants.BypassFiltersEventId, "GetOrganizationReportDataAsync called with empty ReportId"); - throw new BadRequestException("ReportId is required."); - } - - var reportDataResponse = await _organizationReportRepo.GetReportDataAsync(reportId); - - if (reportDataResponse == null) - { - _logger.LogWarning(Constants.BypassFiltersEventId, "No report data found for organization {organizationId} and report {reportId}", - organizationId, reportId); - throw new NotFoundException("Organization report data not found."); - } - - _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully retrieved organization report data for organization {organizationId} and report {reportId}", - organizationId, reportId); - - return reportDataResponse; - } - catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) - { - _logger.LogError(ex, "Error fetching organization report data for organization {organizationId} and report {reportId}", - organizationId, reportId); - throw; - } - } -} diff --git a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportQuery.cs index b0bf9e450af9..7928b2956f87 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportQuery.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportQuery.cs @@ -19,13 +19,21 @@ public GetOrganizationReportQuery( _logger = logger; } - public async Task GetOrganizationReportAsync(Guid reportId) + public async Task GetLatestOrganizationReportAsync(Guid organizationId) { - if (reportId == Guid.Empty) + _logger.LogInformation(Constants.BypassFiltersEventId, "Fetching latest organization report for organization {organizationId}", organizationId); + var result = await _organizationReportRepo.GetLatestByOrganizationIdAsync(organizationId); + + if (result == null) { - throw new BadRequestException("Id of report is required."); + throw new NotFoundException($"No report found for organization: {organizationId}"); } + return result; + } + + public async Task GetOrganizationReportAsync(Guid reportId) + { _logger.LogInformation(Constants.BypassFiltersEventId, "Fetching organization reports for organization by Id: {reportId}", reportId); var results = await _organizationReportRepo.GetByIdAsync(reportId); @@ -37,15 +45,4 @@ public async Task GetOrganizationReportAsync(Guid reportId) return results; } - - public async Task GetLatestOrganizationReportAsync(Guid organizationId) - { - if (organizationId == Guid.Empty) - { - throw new BadRequestException("OrganizationId is required."); - } - - _logger.LogInformation(Constants.BypassFiltersEventId, "Fetching latest organization report for organization {organizationId}", organizationId); - return await _organizationReportRepo.GetLatestByOrganizationIdAsync(organizationId); - } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportSummaryDataQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportSummaryDataQuery.cs index 83ee24a47672..c877dd9e0486 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportSummaryDataQuery.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportSummaryDataQuery.cs @@ -38,6 +38,14 @@ public async Task GetOrganizationReportSu throw new BadRequestException("ReportId is required."); } + var report = await _organizationReportRepo.GetByIdAsync(reportId); + if (report == null || report.OrganizationId != organizationId) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "No summary data found for organization {organizationId} and report {reportId}", + organizationId, reportId); + throw new NotFoundException("Organization report summary data not found."); + } + var summaryDataResponse = await _organizationReportRepo.GetSummaryDataAsync(reportId); if (summaryDataResponse == null) 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/IGetOrganizationReportDataQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportDataQuery.cs deleted file mode 100644 index 3817fa03d20c..000000000000 --- a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportDataQuery.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Bit.Core.Dirt.Models.Data; - -namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; - -public interface IGetOrganizationReportDataQuery -{ - Task GetOrganizationReportDataAsync(Guid organizationId, Guid reportId); -} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportDataCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportDataCommand.cs deleted file mode 100644 index cb212714f21d..000000000000 --- a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportDataCommand.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Bit.Core.Dirt.Entities; -using Bit.Core.Dirt.Reports.ReportFeatures.Requests; - -namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; - -public interface IUpdateOrganizationReportDataCommand -{ - Task UpdateOrganizationReportDataAsync(UpdateOrganizationReportDataRequest 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..c34b622710b6 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs @@ -23,9 +23,12 @@ public static void AddReportingServices(this IServiceCollection services, IGloba services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); - 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 deleted file mode 100644 index 673a3f2ab8e5..000000000000 --- a/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportDataRequest.cs +++ /dev/null @@ -1,11 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -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; } -} 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..1d8e82293aac --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportV2Request.cs @@ -0,0 +1,12 @@ +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; } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportCommand.cs index 32b1815ebeea..1f53847fd646 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportCommand.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportCommand.cs @@ -91,16 +91,6 @@ public async Task UpdateOrganizationReportAsync(UpdateOrgani private async Task<(bool IsValid, string errorMessage)> ValidateRequestAsync(UpdateOrganizationReportRequest 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) { diff --git a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataCommand.cs deleted file mode 100644 index f81d24c3d74a..000000000000 --- a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataCommand.cs +++ /dev/null @@ -1,96 +0,0 @@ -using Bit.Core.Dirt.Entities; -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 Microsoft.Extensions.Logging; - -namespace Bit.Core.Dirt.Reports.ReportFeatures; - -public class UpdateOrganizationReportDataCommand : IUpdateOrganizationReportDataCommand -{ - private readonly IOrganizationRepository _organizationRepo; - private readonly IOrganizationReportRepository _organizationReportRepo; - private readonly ILogger _logger; - - public UpdateOrganizationReportDataCommand( - IOrganizationRepository organizationRepository, - IOrganizationReportRepository organizationReportRepository, - ILogger logger) - { - _organizationRepo = organizationRepository; - _organizationReportRepo = organizationReportRepository; - _logger = logger; - } - - public async Task UpdateOrganizationReportDataAsync(UpdateOrganizationReportDataRequest request) - { - try - { - _logger.LogInformation(Constants.BypassFiltersEventId, "Updating organization report data {reportId} for organization {organizationId}", - request.ReportId, request.OrganizationId); - - var (isValid, errorMessage) = await ValidateRequestAsync(request); - if (!isValid) - { - _logger.LogWarning(Constants.BypassFiltersEventId, "Failed to update organization report data {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"); - } - - var updatedReport = await _organizationReportRepo.UpdateReportDataAsync(request.OrganizationId, request.ReportId, request.ReportData); - - _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully updated organization report data {reportId} for organization {organizationId}", - request.ReportId, request.OrganizationId); - - return updatedReport; - } - catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) - { - _logger.LogError(ex, "Error updating organization report data {reportId} for organization {organizationId}", - request.ReportId, request.OrganizationId); - throw; - } - } - - private async Task<(bool IsValid, string errorMessage)> ValidateRequestAsync(UpdateOrganizationReportDataRequest 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"); - } - - if (string.IsNullOrWhiteSpace(request.ReportData)) - { - return (false, "Report Data is required"); - } - - return (true, string.Empty); - } -} 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..f37300a75430 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportV2Command.cs @@ -0,0 +1,110 @@ +using Bit.Core.Dirt.Entities; +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.DependencyInjection; +using Microsoft.Extensions.Logging; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class UpdateOrganizationReportV2Command : IUpdateOrganizationReportV2Command +{ + private readonly IOrganizationRepository _organizationRepo; + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly ILogger _logger; + private readonly IFusionCache _cache; + + public UpdateOrganizationReportV2Command( + IOrganizationRepository organizationRepository, + IOrganizationReportRepository organizationReportRepository, + ILogger logger, + [FromKeyedServices(OrganizationReportCacheConstants.CacheName)] IFusionCache cache) + { + _organizationRepo = organizationRepository; + _organizationReportRepo = organizationReportRepository; + _logger = logger; + _cache = cache; + } + + 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) + { + throw new BadRequestException(errorMessage); + } + + var existingReport = await _organizationReportRepo.GetByIdAsync(request.ReportId); + if (existingReport == null) + { + throw new NotFoundException("Organization report not found"); + } + + if (existingReport.OrganizationId != 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; + } + + existingReport.RevisionDate = DateTime.UtcNow; + await _organizationReportRepo.ReplaceAsync(existingReport); + + await _cache.RemoveByTagAsync(OrganizationReportCacheConstants.BuildCacheTagForOrganizationReports(request.OrganizationId)); + + _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) + { + 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..82b4e0ccc281 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/ValidateOrganizationReportFileCommand.cs @@ -0,0 +1,68 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.Services; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Utilities; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class ValidateOrganizationReportFileCommand : IValidateOrganizationReportFileCommand +{ + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly IOrganizationReportStorageService _storageService; + private readonly ILogger _logger; + private readonly IFusionCache _cache; + + public ValidateOrganizationReportFileCommand( + IOrganizationReportRepository organizationReportRepo, + IOrganizationReportStorageService storageService, + ILogger logger, + [FromKeyedServices(OrganizationReportCacheConstants.CacheName)] IFusionCache cache) + { + _organizationReportRepo = organizationReportRepo; + _storageService = storageService; + _logger = logger; + _cache = cache; + } + + 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 (length < 0) + { + _logger.LogWarning("Could not validate report {ReportId} due to storage error. Skipping.", report.Id); + return false; + } + + 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); + await _cache.RemoveByTagAsync( + OrganizationReportCacheConstants.BuildCacheTagForOrganizationReports(report.OrganizationId)); + return false; + } + + fileData.Validated = true; + fileData.Size = length; + report.SetReportFile(fileData); + report.RevisionDate = DateTime.UtcNow; + await _organizationReportRepo.ReplaceAsync(report); + await _cache.RemoveByTagAsync( + OrganizationReportCacheConstants.BuildCacheTagForOrganizationReports(report.OrganizationId)); + 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..99796e541d2d --- /dev/null +++ b/src/Core/Dirt/Reports/Services/AzureOrganizationReportStorageService.cs @@ -0,0 +1,137 @@ +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(); + } + } + + public Task GetReportReadStreamAsync(OrganizationReport report, ReportFile fileData) => + throw new NotImplementedException(); + + 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..7aef9902c521 --- /dev/null +++ b/src/Core/Dirt/Reports/Services/IOrganizationReportStorageService.cs @@ -0,0 +1,26 @@ +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); + + /// + /// Opens a read stream for the report file on disk. + /// Only used by local/self-hosted storage implementations. + /// + Task GetReportReadStreamAsync(OrganizationReport report, ReportFile fileData); +} diff --git a/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs b/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs new file mode 100644 index 000000000000..5710e65deca1 --- /dev/null +++ b/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs @@ -0,0 +1,108 @@ +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 _apiBaseUrl; + + public FileUploadType FileUploadType => FileUploadType.Direct; + + public LocalOrganizationReportStorageService( + GlobalSettings globalSettings) + { + _baseDirPath = globalSettings.OrganizationReport.BaseDirectory; + _apiBaseUrl = globalSettings.BaseServiceUri.Api; + } + + 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) + { + return Task.FromResult( + $"{_apiBaseUrl}/reports/organizations/{report.OrganizationId}/{report.Id}/file/download"); + } + + public Task GetReportReadStreamAsync(OrganizationReport report, ReportFile fileData) + { + var path = Path.Combine(_baseDirPath, RelativePath(report, fileData.Id!, fileData.FileName)); + EnsurePathWithinBaseDir(path); + if (!File.Exists(path)) + { + return Task.FromResult(null); + } + + return Task.FromResult(File.OpenRead(path)); + } + + 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..1b8baebfecd9 --- /dev/null +++ b/src/Core/Dirt/Reports/Services/NoopOrganizationReportStorageService.cs @@ -0,0 +1,22 @@ +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; + + public Task GetReportReadStreamAsync(OrganizationReport report, ReportFile fileData) => Task.FromResult(null); +} diff --git a/src/Core/Dirt/Repositories/IOrganizationReportRepository.cs b/src/Core/Dirt/Repositories/IOrganizationReportRepository.cs index b4c2f905669d..fb85361a63ff 100644 --- a/src/Core/Dirt/Repositories/IOrganizationReportRepository.cs +++ b/src/Core/Dirt/Repositories/IOrganizationReportRepository.cs @@ -15,10 +15,6 @@ public interface IOrganizationReportRepository : IRepository GetSummaryDataAsync(Guid reportId); Task UpdateSummaryDataAsync(Guid orgId, Guid reportId, string summaryData); - // ReportData methods - Task GetReportDataAsync(Guid reportId); - Task UpdateReportDataAsync(Guid orgId, Guid reportId, string reportData); - // ApplicationData methods Task GetApplicationDataAsync(Guid reportId); Task UpdateApplicationDataAsync(Guid orgId, Guid reportId, string applicationData); diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index 14f10438af00..ce0720d0e1bd 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/Infrastructure.Dapper/Dirt/OrganizationReportRepository.cs b/src/Infrastructure.Dapper/Dirt/OrganizationReportRepository.cs index 0472efaac192..2a6ee83985f7 100644 --- a/src/Infrastructure.Dapper/Dirt/OrganizationReportRepository.cs +++ b/src/Infrastructure.Dapper/Dirt/OrganizationReportRepository.cs @@ -99,44 +99,6 @@ public async Task> GetSummary } } - public async Task GetReportDataAsync(Guid reportId) - { - using (var connection = new SqlConnection(ReadOnlyConnectionString)) - { - var result = await connection.QuerySingleOrDefaultAsync( - $"[{Schema}].[OrganizationReport_GetReportDataById]", - new { Id = reportId }, - commandType: CommandType.StoredProcedure); - - return result; - } - } - - public async Task UpdateReportDataAsync(Guid organizationId, Guid reportId, string reportData) - { - using (var connection = new SqlConnection(ConnectionString)) - { - var parameters = new - { - OrganizationId = organizationId, - Id = reportId, - ReportData = reportData, - RevisionDate = DateTime.UtcNow - }; - - await connection.ExecuteAsync( - $"[{Schema}].[OrganizationReport_UpdateReportData]", - parameters, - commandType: CommandType.StoredProcedure); - - // Return the updated report - return await connection.QuerySingleOrDefaultAsync( - $"[{Schema}].[OrganizationReport_ReadById]", - new { Id = reportId }, - commandType: CommandType.StoredProcedure); - } - } - public async Task GetApplicationDataAsync(Guid reportId) { using (var connection = new SqlConnection(ReadOnlyConnectionString)) diff --git a/src/Infrastructure.EntityFramework/Dirt/Repositories/OrganizationReportRepository.cs b/src/Infrastructure.EntityFramework/Dirt/Repositories/OrganizationReportRepository.cs index c06519f12a16..6691079179ec 100644 --- a/src/Infrastructure.EntityFramework/Dirt/Repositories/OrganizationReportRepository.cs +++ b/src/Infrastructure.EntityFramework/Dirt/Repositories/OrganizationReportRepository.cs @@ -108,48 +108,6 @@ public async Task> GetSummary } } - public async Task GetReportDataAsync(Guid reportId) - { - using (var scope = ServiceScopeFactory.CreateScope()) - { - var dbContext = GetDatabaseContext(scope); - - var result = await dbContext.OrganizationReports - .Where(p => p.Id == reportId) - .Select(p => new OrganizationReportDataResponse - { - ReportData = p.ReportData - }) - .FirstOrDefaultAsync(); - - return result; - } - } - - public async Task UpdateReportDataAsync(Guid organizationId, Guid reportId, string reportData) - { - using (var scope = ServiceScopeFactory.CreateScope()) - { - var dbContext = GetDatabaseContext(scope); - - // Update only ReportData and RevisionDate - await dbContext.OrganizationReports - .Where(p => p.Id == reportId && p.OrganizationId == organizationId) - .UpdateAsync(p => new Models.OrganizationReport - { - ReportData = reportData, - RevisionDate = DateTime.UtcNow - }); - - // Return the updated report - var updatedReport = await dbContext.OrganizationReports - .Where(p => p.Id == reportId) - .FirstOrDefaultAsync(); - - return Mapper.Map(updatedReport); - } - } - public async Task GetApplicationDataAsync(Guid reportId) { using (var scope = ServiceScopeFactory.CreateScope()) diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 85886027ac2d..1bdb6bf00c9a 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -29,6 +29,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; @@ -363,6 +364,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..66b50d71dfb8 100644 --- a/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs +++ b/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs @@ -1,16 +1,27 @@ using Bit.Api.Dirt.Controllers; +using Bit.Api.Dirt.Models.Request; 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.Dirt.Repositories; +using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Services; +using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; using NSubstitute; +using NSubstitute.ExceptionExtensions; using Xunit; +using ZiggyCreatures.Caching.Fusion; namespace Bit.Api.Test.Dirt; @@ -18,30 +29,64 @@ namespace Bit.Api.Test.Dirt; [SutProviderCustomize] public class OrganizationReportControllerTests { - #region Whole OrganizationReport Endpoints + // GetLatestOrganizationReportAsync [Theory, BitAutoData] - public async Task GetLatestOrganizationReportAsync_WithValidOrgId_ReturnsOkResult( + public async Task GetLatestOrganizationReportAsync_WithValidatedFile_ReturnsOkWithDownloadUrl( SutProvider sutProvider, Guid orgId, - OrganizationReport expectedReport) + OrganizationReport expectedReport, + string downloadUrl) { // Arrange - sutProvider.GetDependency() - .AccessReports(orgId) + var reportFile = new ReportFile { Id = "file-id", FileName = "report.json", Size = 1024, Validated = true }; + expectedReport.SetReportFile(reportFile); + + SetupAuthorization(sutProvider, orgId); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) .Returns(true); 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 expectedResponse = new OrganizationReportResponseModel(expectedReport); - Assert.Equivalent(expectedResponse, okResult.Value); + var response = Assert.IsType(okResult.Value); + Assert.Equal(downloadUrl, response.ReportFileDownloadUrl); + } + + [Theory, BitAutoData] + public async Task GetLatestOrganizationReportAsync_WithNoFile_ReturnsOkWithNullDownloadUrl( + SutProvider sutProvider, + Guid orgId, + OrganizationReport expectedReport) + { + // Arrange + expectedReport.ReportFile = null; + + SetupAuthorization(sutProvider, orgId); + + sutProvider.GetDependency() + .GetLatestOrganizationReportAsync(orgId) + .Returns(expectedReport); + + // Act + var result = await sutProvider.Sut.GetLatestOrganizationReportAsync(orgId); + + // Assert + var okResult = Assert.IsType(result); + var response = Assert.IsType(okResult.Value); + Assert.Null(response.ReportFileDownloadUrl); } [Theory, BitAutoData] @@ -52,20 +97,19 @@ public async Task GetLatestOrganizationReportAsync_WithoutAccess_ThrowsNotFoundE // Arrange 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_NoUseRiskInsights_ThrowsBadRequestException( SutProvider sutProvider, Guid orgId) { @@ -74,149 +118,214 @@ public async Task GetLatestOrganizationReportAsync_WhenNoReportFound_ReturnsOkWi .AccessReports(orgId) .Returns(true); - sutProvider.GetDependency() - .GetLatestOrganizationReportAsync(orgId) - .Returns((OrganizationReport)null); + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(orgId) + .Returns(new OrganizationAbility { UseRiskInsights = false }); - // Act - var result = await sutProvider.Sut.GetLatestOrganizationReportAsync(orgId); + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetLatestOrganizationReportAsync(orgId)); - // Assert - var okResult = Assert.IsType(result); - Assert.Null(okResult.Value); + await sutProvider.GetDependency() + .DidNotReceive() + .GetLatestOrganizationReportAsync(Arg.Any()); } + // CreateOrganizationReportAsync - V1 (flag off) + [Theory, BitAutoData] - public async Task GetLatestOrganizationReportAsync_CallsCorrectMethods( + public async Task CreateOrganizationReportAsync_V1_WithValidRequest_ReturnsOkResult( SutProvider sutProvider, Guid orgId, + AddOrganizationReportRequestModel request, OrganizationReport expectedReport) { // Arrange - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); + expectedReport.ReportFile = null; - sutProvider.GetDependency() - .GetLatestOrganizationReportAsync(orgId) + SetupAuthorization(sutProvider, orgId); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); + + sutProvider.GetDependency() + .AddOrganizationReportAsync(Arg.Any()) .Returns(expectedReport); // Act - await sutProvider.Sut.GetLatestOrganizationReportAsync(orgId); + var result = await sutProvider.Sut.CreateOrganizationReportAsync(orgId, request); // Assert - await sutProvider.GetDependency() - .Received(1) - .AccessReports(orgId); - - await sutProvider.GetDependency() - .Received(1) - .GetLatestOrganizationReportAsync(orgId); + 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, + AddOrganizationReportRequestModel 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()); + } + // CreateOrganizationReportAsync - V2 (flag on) [Theory, BitAutoData] - public async Task GetOrganizationReportAsync_WithValidIds_ReturnsOkResult( + public async Task CreateOrganizationReportAsync_V2_WithValidRequest_ReturnsFileResponseModel( SutProvider sutProvider, Guid orgId, - Guid reportId, - OrganizationReport expectedReport) + AddOrganizationReportRequestModel request, + OrganizationReport expectedReport, + string uploadUrl) { // Arrange - expectedReport.OrganizationId = orgId; - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); + request.FileSize = 1024; - sutProvider.GetDependency() - .GetOrganizationReportAsync(reportId) + var reportFile = new ReportFile { Id = "file-id", FileName = "report.json", Size = 1024, Validated = false }; + expectedReport.SetReportFile(reportFile); + + SetupV2Authorization(sutProvider, orgId); + + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) .Returns(expectedReport); + sutProvider.GetDependency() + .GetReportFileUploadUrlAsync(expectedReport, Arg.Any()) + .Returns(uploadUrl); + + sutProvider.GetDependency() + .FileUploadType + .Returns(FileUploadType.Azure); + // Act - var result = await sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId); + var result = await sutProvider.Sut.CreateOrganizationReportAsync(orgId, request); // Assert var okResult = Assert.IsType(result); - Assert.Equal(expectedReport, okResult.Value); + 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 GetOrganizationReportAsync_WithoutAccess_ThrowsNotFoundException( + public async Task CreateOrganizationReportAsync_V2_EmptyOrgId_ThrowsBadRequestException( + SutProvider sutProvider, + AddOrganizationReportRequestModel request) + { + // Arrange + var emptyOrgId = Guid.Empty; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.CreateOrganizationReportAsync(emptyOrgId, request)); + + Assert.Equal("OrganizationId is required.", exception.Message); + } + + [Theory, BitAutoData] + public async Task CreateOrganizationReportAsync_V2_MissingFileSize_ThrowsBadRequestException( SutProvider sutProvider, Guid orgId, - Guid reportId) + AddOrganizationReportRequestModel request) { // Arrange - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(Task.FromResult(false)); + request.FileSize = null; + + SetupV2Authorization(sutProvider, orgId); // Act & Assert - await Assert.ThrowsAsync(() => - sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId)); + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.CreateOrganizationReportAsync(orgId, request)); - // Verify that the query was not called - await sutProvider.GetDependency() - .DidNotReceive() - .GetOrganizationReportAsync(Arg.Any()); + Assert.Equal("File size is required.", exception.Message); } [Theory, BitAutoData] - public async Task GetOrganizationReportAsync_WhenReportNotFound_ThrowsNotFoundException( + public async Task CreateOrganizationReportAsync_V2_WithoutAccess_ThrowsNotFoundException( SutProvider sutProvider, Guid orgId, - Guid reportId) + AddOrganizationReportRequestModel request) { // Arrange - sutProvider.GetDependency() - .AccessReports(orgId) + request.FileSize = 1024; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) .Returns(true); - sutProvider.GetDependency() - .GetOrganizationReportAsync(reportId) - .Returns((OrganizationReport)null); + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(false); // Act & Assert - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId)); + await Assert.ThrowsAsync(() => + sutProvider.Sut.CreateOrganizationReportAsync(orgId, request)); - Assert.Equal("Report not found for the specified organization.", exception.Message); + await sutProvider.GetDependency() + .DidNotReceive() + .CreateAsync(Arg.Any()); } + // GetOrganizationReportAsync + [Theory, BitAutoData] - public async Task GetOrganizationReportAsync_CallsCorrectMethods( + public async Task GetOrganizationReportAsync_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); + + SetupAuthorization(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) - .AccessReports(orgId); - - 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 GetOrganizationReportAsync_WithValidAccess_UsesCorrectReportId( + public async Task GetOrganizationReportAsync_WithNoFile_ReturnsOkWithoutDownloadUrl( SutProvider sutProvider, Guid orgId, Guid reportId, @@ -224,55 +333,51 @@ public async Task GetOrganizationReportAsync_WithValidAccess_UsesCorrectReportId { // Arrange expectedReport.OrganizationId = orgId; - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); + expectedReport.ReportFile = null; + + SetupAuthorization(sutProvider, orgId); sutProvider.GetDependency() .GetOrganizationReportAsync(reportId) .Returns(expectedReport); // 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.Null(response.ReportFileDownloadUrl); } [Theory, BitAutoData] - public async Task CreateOrganizationReportAsync_WithValidRequest_ReturnsOkResult( + public async Task GetOrganizationReportAsync_WithOrgMismatch_ThrowsBadRequestException( SutProvider sutProvider, Guid orgId, - AddOrganizationReportRequest request, + Guid reportId, OrganizationReport expectedReport) { // Arrange - request.OrganizationId = orgId; + expectedReport.OrganizationId = Guid.NewGuid(); - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); + SetupAuthorization(sutProvider, orgId); - sutProvider.GetDependency() - .AddOrganizationReportAsync(request) + sutProvider.GetDependency() + .GetOrganizationReportAsync(reportId) .Returns(expectedReport); - // Act - var result = await sutProvider.Sut.CreateOrganizationReportAsync(orgId, request); + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId)); - // Assert - var okResult = Assert.IsType(result); - var expectedResponse = new OrganizationReportResponseModel(expectedReport); - Assert.Equivalent(expectedResponse, okResult.Value); + Assert.Equal("Invalid report ID", exception.Message); } [Theory, BitAutoData] - public async Task CreateOrganizationReportAsync_WithoutAccess_ThrowsNotFoundException( + public async Task GetOrganizationReportAsync_WithoutAccess_ThrowsNotFoundException( SutProvider sutProvider, Guid orgId, - AddOrganizationReportRequest request) + Guid reportId) { // Arrange sutProvider.GetDependency() @@ -281,472 +386,524 @@ public async Task CreateOrganizationReportAsync_WithoutAccess_ThrowsNotFoundExce // Act & Assert await Assert.ThrowsAsync(() => - sutProvider.Sut.CreateOrganizationReportAsync(orgId, request)); + sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId)); - // Verify that the command was not called - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceive() - .AddOrganizationReportAsync(Arg.Any()); + .GetOrganizationReportAsync(Arg.Any()); } + // DeleteOrganizationReportAsync + [Theory, BitAutoData] - public async Task CreateOrganizationReportAsync_WithMismatchedOrgId_ThrowsBadRequestException( + public async Task DeleteOrganizationReportAsync_WithFile_DeletesDbThenStorage( SutProvider sutProvider, Guid orgId, - AddOrganizationReportRequest request) + OrganizationReport report) { // Arrange - request.OrganizationId = Guid.NewGuid(); // Different from orgId + var reportFile = new ReportFile { Id = "file-id", FileName = "report.json", Size = 1024, Validated = true }; + report.OrganizationId = orgId; + report.SetReportFile(reportFile); - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); + SetupAuthorization(sutProvider, orgId); - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.CreateOrganizationReportAsync(orgId, request)); + sutProvider.GetDependency() + .GetOrganizationReportAsync(report.Id) + .Returns(report); - Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); + // Act + await sutProvider.Sut.DeleteOrganizationReportAsync(orgId, report.Id); - // Verify that the command was not called - await sutProvider.GetDependency() - .DidNotReceive() - .AddOrganizationReportAsync(Arg.Any()); + // Assert + await sutProvider.GetDependency() + .Received(1) + .DeleteAsync(report); + + await sutProvider.GetDependency() + .Received(1) + .DeleteReportFilesAsync(report, "file-id"); + + await sutProvider.GetDependency() + .Received(1) + .RemoveByTagAsync( + OrganizationReportCacheConstants.BuildCacheTagForOrganizationReports(orgId)); } [Theory, BitAutoData] - public async Task CreateOrganizationReportAsync_CallsCorrectMethods( + public async Task DeleteOrganizationReportAsync_WithNoFile_DeletesDbOnly( SutProvider sutProvider, Guid orgId, - AddOrganizationReportRequest request, - OrganizationReport expectedReport) + OrganizationReport report) { // Arrange - request.OrganizationId = orgId; + report.OrganizationId = orgId; + report.ReportFile = null; - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); + SetupAuthorization(sutProvider, orgId); - sutProvider.GetDependency() - .AddOrganizationReportAsync(request) - .Returns(expectedReport); + sutProvider.GetDependency() + .GetOrganizationReportAsync(report.Id) + .Returns(report); // Act - await sutProvider.Sut.CreateOrganizationReportAsync(orgId, request); + await sutProvider.Sut.DeleteOrganizationReportAsync(orgId, report.Id); // Assert - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) - .AccessReports(orgId); + .DeleteAsync(report); - await sutProvider.GetDependency() + await sutProvider.GetDependency() + .DidNotReceive() + .DeleteReportFilesAsync(Arg.Any(), Arg.Any()); + + await sutProvider.GetDependency() .Received(1) - .AddOrganizationReportAsync(request); + .RemoveByTagAsync( + OrganizationReportCacheConstants.BuildCacheTagForOrganizationReports(orgId)); } [Theory, BitAutoData] - public async Task UpdateOrganizationReportAsync_WithValidRequest_ReturnsOkResult( + public async Task DeleteOrganizationReportAsync_ReportNotFound_ThrowsNotFoundException( SutProvider sutProvider, Guid orgId, - UpdateOrganizationReportRequest request, - OrganizationReport expectedReport) + Guid reportId) { // Arrange - request.OrganizationId = orgId; - - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); + SetupAuthorization(sutProvider, orgId); - sutProvider.GetDependency() - .UpdateOrganizationReportAsync(request) - .Returns(expectedReport); - - // Act - var result = await sutProvider.Sut.UpdateOrganizationReportAsync(orgId, request); + sutProvider.GetDependency() + .GetOrganizationReportAsync(reportId) + .Throws(new NotFoundException()); - // Assert - var okResult = Assert.IsType(result); - var expectedResponse = new OrganizationReportResponseModel(expectedReport); - Assert.Equivalent(expectedResponse, okResult.Value); + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.DeleteOrganizationReportAsync(orgId, reportId)); } [Theory, BitAutoData] - public async Task UpdateOrganizationReportAsync_WithoutAccess_ThrowsNotFoundException( + public async Task DeleteOrganizationReportAsync_OrgMismatch_ThrowsBadRequestException( SutProvider sutProvider, Guid orgId, - UpdateOrganizationReportRequest request) + OrganizationReport report) { // Arrange - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(false); + report.OrganizationId = Guid.NewGuid(); + + SetupAuthorization(sutProvider, orgId); + + sutProvider.GetDependency() + .GetOrganizationReportAsync(report.Id) + .Returns(report); // Act & Assert - await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateOrganizationReportAsync(orgId, request)); + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.DeleteOrganizationReportAsync(orgId, report.Id)); - // Verify that the command was not called - await sutProvider.GetDependency() + Assert.Equal("Invalid report ID", exception.Message); + + await sutProvider.GetDependency() .DidNotReceive() - .UpdateOrganizationReportAsync(Arg.Any()); + .DeleteAsync(Arg.Any()); } [Theory, BitAutoData] - public async Task UpdateOrganizationReportAsync_WithMismatchedOrgId_ThrowsBadRequestException( + public async Task DeleteOrganizationReportAsync_WithoutAccess_ThrowsNotFoundException( SutProvider sutProvider, Guid orgId, - UpdateOrganizationReportRequest request) + Guid reportId) { // Arrange - request.OrganizationId = Guid.NewGuid(); // Different from orgId - sutProvider.GetDependency() .AccessReports(orgId) - .Returns(true); + .Returns(false); // Act & Assert - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateOrganizationReportAsync(orgId, request)); - - Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); + await Assert.ThrowsAsync(() => + sutProvider.Sut.DeleteOrganizationReportAsync(orgId, reportId)); - // Verify that the command was not called - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceive() - .UpdateOrganizationReportAsync(Arg.Any()); + .DeleteAsync(Arg.Any()); } + // RenewFileUploadUrlAsync + [Theory, BitAutoData] - public async Task UpdateOrganizationReportAsync_CallsCorrectMethods( + public async Task RenewFileUploadUrlAsync_WithUnvalidatedFile_ReturnsRenewedUrl( SutProvider sutProvider, Guid orgId, - UpdateOrganizationReportRequest request, - OrganizationReport expectedReport) + OrganizationReport report, + string uploadUrl) { // Arrange - request.OrganizationId = orgId; + var reportFile = new ReportFile { Id = "file-id", FileName = "report.json", Size = 1024, Validated = false }; + report.OrganizationId = orgId; + report.SetReportFile(reportFile); - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); + SetupV2Authorization(sutProvider, orgId); - sutProvider.GetDependency() - .UpdateOrganizationReportAsync(request) - .Returns(expectedReport); + sutProvider.GetDependency() + .GetOrganizationReportAsync(report.Id) + .Returns(report); + + sutProvider.GetDependency() + .GetReportFileUploadUrlAsync(report, Arg.Any()) + .Returns(uploadUrl); + + sutProvider.GetDependency() + .FileUploadType + .Returns(FileUploadType.Azure); // Act - await sutProvider.Sut.UpdateOrganizationReportAsync(orgId, request); + var result = await sutProvider.Sut.RenewFileUploadUrlAsync(orgId, report.Id, "file-id"); // Assert - await sutProvider.GetDependency() - .Received(1) - .AccessReports(orgId); - - await sutProvider.GetDependency() - .Received(1) - .UpdateOrganizationReportAsync(request); + Assert.Equal(uploadUrl, result.ReportFileUploadUrl); + Assert.Equal(FileUploadType.Azure, result.FileUploadType); + Assert.NotNull(result.ReportResponse); } - #endregion + [Theory, BitAutoData] + public async Task RenewFileUploadUrlAsync_ReportNotFound_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + Guid reportId) + { + // Arrange + SetupV2Authorization(sutProvider, orgId); + + sutProvider.GetDependency() + .GetOrganizationReportAsync(reportId) + .Throws(new NotFoundException()); - #region SummaryData Field Endpoints + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.RenewFileUploadUrlAsync(orgId, reportId, "file-id")); + } [Theory, BitAutoData] - public async Task GetOrganizationReportSummaryDataByDateRangeAsync_WithValidParameters_ReturnsOkResult( + public async Task RenewFileUploadUrlAsync_OrgMismatch_ThrowsBadRequestException( SutProvider sutProvider, Guid orgId, - DateTime startDate, - DateTime endDate, - List expectedSummaryData) + OrganizationReport report) { // Arrange - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); + report.OrganizationId = Guid.NewGuid(); - sutProvider.GetDependency() - .GetOrganizationReportSummaryDataByDateRangeAsync(orgId, startDate, endDate) - .Returns(expectedSummaryData); + SetupV2Authorization(sutProvider, orgId); - // Act - var result = await sutProvider.Sut.GetOrganizationReportSummaryDataByDateRangeAsync(orgId, startDate, endDate); + sutProvider.GetDependency() + .GetOrganizationReportAsync(report.Id) + .Returns(report); - // Assert - var okResult = Assert.IsType(result); - Assert.Equal(expectedSummaryData, okResult.Value); + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RenewFileUploadUrlAsync(orgId, report.Id, "file-id")); + + Assert.Equal("Invalid report ID", exception.Message); } [Theory, BitAutoData] - public async Task GetOrganizationReportSummaryDataByDateRangeAsync_WithoutAccess_ThrowsNotFoundException( + public async Task RenewFileUploadUrlAsync_FileAlreadyValidated_ThrowsNotFoundException( SutProvider sutProvider, Guid orgId, - DateTime startDate, - DateTime endDate) + OrganizationReport report) { // Arrange - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(false); + var reportFile = new ReportFile { Id = "file-id", FileName = "report.json", Size = 1024, Validated = true }; + report.OrganizationId = orgId; + report.SetReportFile(reportFile); + + SetupV2Authorization(sutProvider, orgId); + + sutProvider.GetDependency() + .GetOrganizationReportAsync(report.Id) + .Returns(report); // Act & Assert await Assert.ThrowsAsync(() => - sutProvider.Sut.GetOrganizationReportSummaryDataByDateRangeAsync(orgId, startDate, endDate)); - - // Verify that the query was not called - await sutProvider.GetDependency() - .DidNotReceive() - .GetOrganizationReportSummaryDataByDateRangeAsync(Arg.Any(), Arg.Any(), Arg.Any()); + sutProvider.Sut.RenewFileUploadUrlAsync(orgId, report.Id, "file-id")); } [Theory, BitAutoData] - public async Task GetOrganizationReportSummaryDataByDateRangeAsync_CallsCorrectMethods( + public async Task RenewFileUploadUrlAsync_NoFileData_ThrowsNotFoundException( SutProvider sutProvider, Guid orgId, - DateTime startDate, - DateTime endDate, - List expectedSummaryData) + OrganizationReport report) { // Arrange - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); + report.OrganizationId = orgId; + report.ReportFile = null; - sutProvider.GetDependency() - .GetOrganizationReportSummaryDataByDateRangeAsync(orgId, startDate, endDate) - .Returns(expectedSummaryData); + SetupV2Authorization(sutProvider, orgId); - // Act - await sutProvider.Sut.GetOrganizationReportSummaryDataByDateRangeAsync(orgId, startDate, endDate); - - // Assert - await sutProvider.GetDependency() - .Received(1) - .AccessReports(orgId); + sutProvider.GetDependency() + .GetOrganizationReportAsync(report.Id) + .Returns(report); - await sutProvider.GetDependency() - .Received(1) - .GetOrganizationReportSummaryDataByDateRangeAsync(orgId, startDate, endDate); + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.RenewFileUploadUrlAsync(orgId, report.Id, "file-id")); } [Theory, BitAutoData] - public async Task GetOrganizationReportSummaryAsync_WithValidIds_ReturnsOkResult( + public async Task RenewFileUploadUrlAsync_MismatchedFileId_ThrowsNotFoundException( SutProvider sutProvider, Guid orgId, - Guid reportId, - OrganizationReportSummaryDataResponse expectedSummaryData) + OrganizationReport report) { // Arrange + var reportFile = new ReportFile { Id = "file-id", FileName = "report.json", Size = 1024, Validated = false }; + report.OrganizationId = orgId; + report.SetReportFile(reportFile); - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); + SetupV2Authorization(sutProvider, orgId); - sutProvider.GetDependency() - .GetOrganizationReportSummaryDataAsync(orgId, reportId) - .Returns(expectedSummaryData); - - // Act - var result = await sutProvider.Sut.GetOrganizationReportSummaryAsync(orgId, reportId); + sutProvider.GetDependency() + .GetOrganizationReportAsync(report.Id) + .Returns(report); - // Assert - var okResult = Assert.IsType(result); - Assert.Equal(expectedSummaryData, okResult.Value); + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.RenewFileUploadUrlAsync(orgId, report.Id, "wrong-file-id")); } [Theory, BitAutoData] - public async Task GetOrganizationReportSummaryAsync_WithoutAccess_ThrowsNotFoundException( + public async Task RenewFileUploadUrlAsync_NullReportFileId_ThrowsBadRequestException( SutProvider sutProvider, Guid orgId, Guid reportId) { // Arrange - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(false); + var report = new OrganizationReport { OrganizationId = orgId }; + SetupV2Authorization(sutProvider, orgId); + sutProvider.GetDependency() + .GetOrganizationReportAsync(reportId) + .Returns(report); // Act & Assert - await Assert.ThrowsAsync(() => - sutProvider.Sut.GetOrganizationReportSummaryAsync(orgId, reportId)); + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RenewFileUploadUrlAsync(orgId, reportId, null)); - // Verify that the query was not called - await sutProvider.GetDependency() - .DidNotReceive() - .GetOrganizationReportSummaryDataAsync(Arg.Any(), Arg.Any()); + Assert.Equal("ReportFileId is required.", exception.Message); } [Theory, BitAutoData] - public async Task UpdateOrganizationReportSummaryAsync_WithValidRequest_ReturnsOkResult( + public async Task RenewFileUploadUrlAsync_EmptyReportFileId_ThrowsBadRequestException( SutProvider sutProvider, Guid orgId, - Guid reportId, - UpdateOrganizationReportSummaryRequest request, - OrganizationReport expectedReport) + Guid reportId) { // Arrange - request.OrganizationId = orgId; - request.ReportId = reportId; - - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); - - sutProvider.GetDependency() - .UpdateOrganizationReportSummaryAsync(request) - .Returns(expectedReport); + var report = new OrganizationReport { OrganizationId = orgId }; + SetupV2Authorization(sutProvider, orgId); + sutProvider.GetDependency() + .GetOrganizationReportAsync(reportId) + .Returns(report); - // Act - var result = await sutProvider.Sut.UpdateOrganizationReportSummaryAsync(orgId, reportId, request); + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RenewFileUploadUrlAsync(orgId, reportId, string.Empty)); - // Assert - var okResult = Assert.IsType(result); - var expectedResponse = new OrganizationReportResponseModel(expectedReport); - Assert.Equivalent(expectedResponse, okResult.Value); + Assert.Equal("ReportFileId is required.", exception.Message); } [Theory, BitAutoData] - public async Task UpdateOrganizationReportSummaryAsync_WithoutAccess_ThrowsNotFoundException( + public async Task DeleteOrganizationReportAsync_StorageFailure_StillCompletesWithoutThrowing( SutProvider sutProvider, Guid orgId, - Guid reportId, - UpdateOrganizationReportSummaryRequest request) + OrganizationReport report) { // Arrange - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(false); + var reportFile = new ReportFile { Id = "file-id", FileName = "report.json", Size = 1024, Validated = true }; + report.OrganizationId = orgId; + report.SetReportFile(reportFile); - // Act & Assert - await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateOrganizationReportSummaryAsync(orgId, reportId, request)); + SetupAuthorization(sutProvider, orgId); - // Verify that the command was not called - await sutProvider.GetDependency() - .DidNotReceive() - .UpdateOrganizationReportSummaryAsync(Arg.Any()); + sutProvider.GetDependency() + .GetOrganizationReportAsync(report.Id) + .Returns(report); + + sutProvider.GetDependency() + .DeleteReportFilesAsync(report, "file-id") + .ThrowsAsync(new Exception("Azure storage unavailable")); + + // Act — should not throw despite storage failure + await sutProvider.Sut.DeleteOrganizationReportAsync(orgId, report.Id); + + // Assert — DB delete and cache invalidation still happened + await sutProvider.GetDependency() + .Received(1) + .DeleteAsync(report); + + await sutProvider.GetDependency() + .Received(1) + .RemoveByTagAsync( + OrganizationReportCacheConstants.BuildCacheTagForOrganizationReports(orgId)); + + sutProvider.GetDependency>() + .Received(1) + .Log( + LogLevel.Warning, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>()); } + // UpdateOrganizationReportAsync - V1 (flag off) + [Theory, BitAutoData] - public async Task UpdateOrganizationReportSummaryAsync_WithMismatchedOrgId_ThrowsBadRequestException( + public async Task UpdateOrganizationReportAsync_V1_WithValidRequest_ReturnsOkResult( SutProvider sutProvider, Guid orgId, Guid reportId, - UpdateOrganizationReportSummaryRequest request) + UpdateOrganizationReportV2RequestModel request, + OrganizationReport expectedReport) { // Arrange - request.OrganizationId = Guid.NewGuid(); // Different from orgId - request.ReportId = reportId; + expectedReport.ReportFile = null; - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); + SetupAuthorization(sutProvider, orgId); - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateOrganizationReportSummaryAsync(orgId, reportId, request)); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); - Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); + sutProvider.GetDependency() + .UpdateOrganizationReportAsync(Arg.Any()) + .Returns(expectedReport); - // Verify that the command was not called - await sutProvider.GetDependency() - .DidNotReceive() - .UpdateOrganizationReportSummaryAsync(Arg.Any()); + // Act + 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 UpdateOrganizationReportSummaryAsync_WithMismatchedReportId_ThrowsBadRequestException( + public async Task UpdateOrganizationReportAsync_V1_WithoutAccess_ThrowsNotFoundException( SutProvider sutProvider, Guid orgId, Guid reportId, - UpdateOrganizationReportSummaryRequest request) + UpdateOrganizationReportV2RequestModel request) { // Arrange - request.OrganizationId = orgId; - request.ReportId = Guid.NewGuid(); // Different from reportId + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); sutProvider.GetDependency() .AccessReports(orgId) - .Returns(true); + .Returns(false); // Act & Assert - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateOrganizationReportSummaryAsync(orgId, reportId, request)); - - Assert.Equal("Report ID in the request body must match the route parameter", exception.Message); + await Assert.ThrowsAsync(() => + sutProvider.Sut.UpdateOrganizationReportAsync(orgId, reportId, request)); - // Verify that the command was not called - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceive() - .UpdateOrganizationReportSummaryAsync(Arg.Any()); + .UpdateOrganizationReportAsync(Arg.Any()); } + // UpdateOrganizationReportAsync - V2 (flag on) + [Theory, BitAutoData] - public async Task UpdateOrganizationReportSummaryAsync_CallsCorrectMethods( + public async Task UpdateOrganizationReportAsync_V2_ReturnsReportResponseModel( SutProvider sutProvider, Guid orgId, Guid reportId, - UpdateOrganizationReportSummaryRequest request, + UpdateOrganizationReportV2RequestModel request, OrganizationReport expectedReport) { // Arrange - request.OrganizationId = orgId; - request.ReportId = reportId; + expectedReport.ReportFile = null; - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); + SetupV2Authorization(sutProvider, orgId); - sutProvider.GetDependency() - .UpdateOrganizationReportSummaryAsync(request) + sutProvider.GetDependency() + .UpdateAsync(Arg.Any()) .Returns(expectedReport); // Act - await sutProvider.Sut.UpdateOrganizationReportSummaryAsync(orgId, reportId, 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) - .UpdateOrganizationReportSummaryAsync(request); + .UpdateAsync(Arg.Any()); } - #endregion - - #region ReportData Field Endpoints - [Theory, BitAutoData] - public async Task GetOrganizationReportDataAsync_WithValidIds_ReturnsOkResult( + public async Task UpdateOrganizationReportAsync_V2_WithoutAccess_ThrowsNotFoundException( SutProvider sutProvider, Guid orgId, Guid reportId, - OrganizationReportDataResponse expectedReportData) + UpdateOrganizationReportV2RequestModel request) { // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(true); + sutProvider.GetDependency() .AccessReports(orgId) - .Returns(true); + .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( + SutProvider sutProvider, + Guid orgId, + DateTime startDate, + DateTime endDate, + List expectedSummaryData) + { + // Arrange + SetupAuthorization(sutProvider, orgId); - sutProvider.GetDependency() - .GetOrganizationReportDataAsync(orgId, reportId) - .Returns(expectedReportData); + sutProvider.GetDependency() + .GetOrganizationReportSummaryDataByDateRangeAsync(orgId, startDate, endDate) + .Returns(expectedSummaryData); // Act - var result = await sutProvider.Sut.GetOrganizationReportDataAsync(orgId, reportId); + var result = await sutProvider.Sut.GetOrganizationReportSummaryDataByDateRangeAsync(orgId, startDate, endDate); // Assert var okResult = Assert.IsType(result); - Assert.Equal(expectedReportData, okResult.Value); + var responseList = Assert.IsAssignableFrom>(okResult.Value); + Assert.Equal(expectedSummaryData.Count, responseList.Count()); } [Theory, BitAutoData] - public async Task GetOrganizationReportDataAsync_WithoutAccess_ThrowsNotFoundException( + public async Task GetOrganizationReportSummaryDataByDateRangeAsync_WithoutAccess_ThrowsNotFoundException( SutProvider sutProvider, Guid orgId, - Guid reportId) + DateTime startDate, + DateTime endDate) { // Arrange sutProvider.GetDependency() @@ -755,78 +912,72 @@ public async Task GetOrganizationReportDataAsync_WithoutAccess_ThrowsNotFoundExc // Act & Assert await Assert.ThrowsAsync(() => - sutProvider.Sut.GetOrganizationReportDataAsync(orgId, reportId)); + sutProvider.Sut.GetOrganizationReportSummaryDataByDateRangeAsync(orgId, startDate, endDate)); // Verify that the query was not called - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceive() - .GetOrganizationReportDataAsync(Arg.Any(), Arg.Any()); + .GetOrganizationReportSummaryDataByDateRangeAsync(Arg.Any(), Arg.Any(), Arg.Any()); } [Theory, BitAutoData] - public async Task GetOrganizationReportDataAsync_CallsCorrectMethods( + public async Task GetOrganizationReportSummaryDataByDateRangeAsync_CallsCorrectMethods( SutProvider sutProvider, Guid orgId, - Guid reportId, - OrganizationReportDataResponse expectedReportData) + DateTime startDate, + DateTime endDate, + List expectedSummaryData) { // Arrange - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); + SetupAuthorization(sutProvider, orgId); - sutProvider.GetDependency() - .GetOrganizationReportDataAsync(orgId, reportId) - .Returns(expectedReportData); + sutProvider.GetDependency() + .GetOrganizationReportSummaryDataByDateRangeAsync(orgId, startDate, endDate) + .Returns(expectedSummaryData); // Act - await sutProvider.Sut.GetOrganizationReportDataAsync(orgId, reportId); + await sutProvider.Sut.GetOrganizationReportSummaryDataByDateRangeAsync(orgId, startDate, endDate); // Assert await sutProvider.GetDependency() .Received(1) .AccessReports(orgId); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) - .GetOrganizationReportDataAsync(orgId, reportId); + .GetOrganizationReportSummaryDataByDateRangeAsync(orgId, startDate, endDate); } [Theory, BitAutoData] - public async Task UpdateOrganizationReportDataAsync_WithValidRequest_ReturnsOkResult( + public async Task GetOrganizationReportSummaryAsync_WithValidIds_ReturnsOkResult( SutProvider sutProvider, Guid orgId, Guid reportId, - UpdateOrganizationReportDataRequest request, - OrganizationReport expectedReport) + OrganizationReportSummaryDataResponse expectedSummaryData) { // Arrange - request.OrganizationId = orgId; - request.ReportId = reportId; - - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); + SetupAuthorization(sutProvider, orgId); - sutProvider.GetDependency() - .UpdateOrganizationReportDataAsync(request) - .Returns(expectedReport); + sutProvider.GetDependency() + .GetOrganizationReportSummaryDataAsync(orgId, reportId) + .Returns(expectedSummaryData); // Act - var result = await sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request); + var result = await sutProvider.Sut.GetOrganizationReportSummaryAsync(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.Equal(expectedSummaryData.SummaryData, response.EncryptedData); + Assert.Equal(expectedSummaryData.ContentEncryptionKey, response.EncryptionKey); + Assert.Equal(expectedSummaryData.RevisionDate, response.Date); } [Theory, BitAutoData] - public async Task UpdateOrganizationReportDataAsync_WithoutAccess_ThrowsNotFoundException( + public async Task GetOrganizationReportSummaryAsync_WithoutAccess_ThrowsNotFoundException( SutProvider sutProvider, Guid orgId, - Guid reportId, - UpdateOrganizationReportDataRequest request) + Guid reportId) { // Arrange sutProvider.GetDependency() @@ -835,104 +986,93 @@ public async Task UpdateOrganizationReportDataAsync_WithoutAccess_ThrowsNotFound // Act & Assert await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request)); + sutProvider.Sut.GetOrganizationReportSummaryAsync(orgId, reportId)); - // Verify that the command was not called - await sutProvider.GetDependency() + // Verify that the query was not called + await sutProvider.GetDependency() .DidNotReceive() - .UpdateOrganizationReportDataAsync(Arg.Any()); + .GetOrganizationReportSummaryDataAsync(Arg.Any(), Arg.Any()); } [Theory, BitAutoData] - public async Task UpdateOrganizationReportDataAsync_WithMismatchedOrgId_ThrowsBadRequestException( + public async Task UpdateOrganizationReportSummaryAsync_WithValidRequest_ReturnsOkResult( SutProvider sutProvider, Guid orgId, Guid reportId, - UpdateOrganizationReportDataRequest request) + UpdateOrganizationReportSummaryRequestModel request, + OrganizationReport expectedReport) { // Arrange - request.OrganizationId = Guid.NewGuid(); // Different from orgId - request.ReportId = reportId; + expectedReport.ReportFile = null; - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); + SetupAuthorization(sutProvider, orgId); - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request)); + sutProvider.GetDependency() + .UpdateOrganizationReportSummaryAsync(Arg.Any()) + .Returns(expectedReport); - Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); + // Act + var result = await sutProvider.Sut.UpdateOrganizationReportSummaryAsync(orgId, reportId, request); - // Verify that the command was not called - await sutProvider.GetDependency() - .DidNotReceive() - .UpdateOrganizationReportDataAsync(Arg.Any()); + // Assert + var okResult = Assert.IsType(result); + var expectedResponse = new OrganizationReportResponseModel(expectedReport); + Assert.Equivalent(expectedResponse, okResult.Value); } [Theory, BitAutoData] - public async Task UpdateOrganizationReportDataAsync_WithMismatchedReportId_ThrowsBadRequestException( + public async Task UpdateOrganizationReportSummaryAsync_WithoutAccess_ThrowsNotFoundException( SutProvider sutProvider, Guid orgId, Guid reportId, - UpdateOrganizationReportDataRequest request) + UpdateOrganizationReportSummaryRequestModel request) { // Arrange - request.OrganizationId = orgId; - request.ReportId = Guid.NewGuid(); // Different from reportId - sutProvider.GetDependency() .AccessReports(orgId) - .Returns(true); + .Returns(false); // Act & Assert - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request)); - - Assert.Equal("Report ID in the request body must match the route parameter", exception.Message); + await Assert.ThrowsAsync(() => + sutProvider.Sut.UpdateOrganizationReportSummaryAsync(orgId, reportId, request)); // Verify that the command was not called - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceive() - .UpdateOrganizationReportDataAsync(Arg.Any()); + .UpdateOrganizationReportSummaryAsync(Arg.Any()); } [Theory, BitAutoData] - public async Task UpdateOrganizationReportDataAsync_CallsCorrectMethods( + public async Task UpdateOrganizationReportSummaryAsync_CallsCorrectMethods( SutProvider sutProvider, Guid orgId, Guid reportId, - UpdateOrganizationReportDataRequest request, + UpdateOrganizationReportSummaryRequestModel request, OrganizationReport expectedReport) { // Arrange - request.OrganizationId = orgId; - request.ReportId = reportId; + expectedReport.ReportFile = null; - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); + SetupAuthorization(sutProvider, orgId); - sutProvider.GetDependency() - .UpdateOrganizationReportDataAsync(request) + sutProvider.GetDependency() + .UpdateOrganizationReportSummaryAsync(Arg.Any()) .Returns(expectedReport); // Act - await sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request); + await sutProvider.Sut.UpdateOrganizationReportSummaryAsync(orgId, reportId, request); // Assert await sutProvider.GetDependency() .Received(1) .AccessReports(orgId); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) - .UpdateOrganizationReportDataAsync(request); + .UpdateOrganizationReportSummaryAsync(Arg.Any()); } - #endregion - - #region ApplicationData Field Endpoints + // ApplicationData Field Endpoints [Theory, BitAutoData] public async Task GetOrganizationReportApplicationDataAsync_WithValidIds_ReturnsOkResult( @@ -942,9 +1082,7 @@ public async Task GetOrganizationReportApplicationDataAsync_WithValidIds_Returns OrganizationReportApplicationDataResponse expectedApplicationData) { // Arrange - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); + SetupAuthorization(sutProvider, orgId); sutProvider.GetDependency() .GetOrganizationReportApplicationDataAsync(orgId, reportId) @@ -955,7 +1093,8 @@ public async Task GetOrganizationReportApplicationDataAsync_WithValidIds_Returns // Assert var okResult = Assert.IsType(result); - Assert.Equal(expectedApplicationData, okResult.Value); + var response = Assert.IsType(okResult.Value); + Assert.Equal(expectedApplicationData.ApplicationData, response.ApplicationData); } [Theory, BitAutoData] @@ -986,9 +1125,7 @@ public async Task GetOrganizationReportApplicationDataAsync_WhenApplicationDataN Guid reportId) { // Arrange - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); + SetupAuthorization(sutProvider, orgId); sutProvider.GetDependency() .GetOrganizationReportApplicationDataAsync(orgId, reportId) @@ -1009,9 +1146,7 @@ public async Task GetOrganizationReportApplicationDataAsync_CallsCorrectMethods( OrganizationReportApplicationDataResponse expectedApplicationData) { // Arrange - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); + SetupAuthorization(sutProvider, orgId); sutProvider.GetDependency() .GetOrganizationReportApplicationDataAsync(orgId, reportId) @@ -1035,20 +1170,16 @@ public async Task UpdateOrganizationReportApplicationDataAsync_WithValidRequest_ SutProvider sutProvider, Guid orgId, Guid reportId, - UpdateOrganizationReportApplicationDataRequest request, + UpdateOrganizationReportApplicationDataRequestModel request, OrganizationReport expectedReport) { // Arrange - request.OrganizationId = orgId; - request.Id = reportId; - expectedReport.Id = request.Id; + expectedReport.ReportFile = null; - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); + SetupAuthorization(sutProvider, orgId); sutProvider.GetDependency() - .UpdateOrganizationReportApplicationDataAsync(request) + .UpdateOrganizationReportApplicationDataAsync(Arg.Any()) .Returns(expectedReport); // Act @@ -1065,7 +1196,7 @@ public async Task UpdateOrganizationReportApplicationDataAsync_WithoutAccess_Thr SutProvider sutProvider, Guid orgId, Guid reportId, - UpdateOrganizationReportApplicationDataRequest request) + UpdateOrganizationReportApplicationDataRequestModel request) { // Arrange sutProvider.GetDependency() @@ -1083,90 +1214,276 @@ await sutProvider.GetDependency } [Theory, BitAutoData] - public async Task UpdateOrganizationReportApplicationDataAsync_WithMismatchedOrgId_ThrowsBadRequestException( + public async Task UpdateOrganizationReportApplicationDataAsync_CallsCorrectMethods( SutProvider sutProvider, Guid orgId, Guid reportId, - UpdateOrganizationReportApplicationDataRequest request) + UpdateOrganizationReportApplicationDataRequestModel request, + OrganizationReport expectedReport) + { + // Arrange + expectedReport.ReportFile = null; + + SetupAuthorization(sutProvider, orgId); + + sutProvider.GetDependency() + .UpdateOrganizationReportApplicationDataAsync(Arg.Any()) + .Returns(expectedReport); + + // Act + await sutProvider.Sut.UpdateOrganizationReportApplicationDataAsync(orgId, reportId, request); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .AccessReports(orgId); + + await sutProvider.GetDependency() + .Received(1) + .UpdateOrganizationReportApplicationDataAsync(Arg.Any()); + } + + // DownloadReportFileAsync + + [Theory, BitAutoData] + public async Task DownloadReportFileAsync_WithValidFile_ReturnsFileStream( + SutProvider sutProvider, + Guid orgId, + OrganizationReport report) + { + // Arrange + var reportFile = new ReportFile { Id = "file-id", FileName = "report.json", Size = 1024, Validated = true }; + report.OrganizationId = orgId; + report.SetReportFile(reportFile); + + SetupAuthorization(sutProvider, orgId); + + sutProvider.GetDependency() + .GetOrganizationReportAsync(report.Id) + .Returns(report); + + var stream = new MemoryStream(new byte[] { 1, 2, 3 }); + sutProvider.GetDependency() + .GetReportReadStreamAsync(report, Arg.Any()) + .Returns(stream); + + // Act + var result = await sutProvider.Sut.DownloadReportFileAsync(orgId, report.Id); + + // Assert + var fileResult = Assert.IsType(result); + Assert.Equal("application/octet-stream", fileResult.ContentType); + Assert.Equal("report.json", fileResult.FileDownloadName); + } + + [Theory, BitAutoData] + public async Task DownloadReportFileAsync_WithNoFileData_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + OrganizationReport report) + { + // Arrange + report.OrganizationId = orgId; + report.ReportFile = null; + + SetupAuthorization(sutProvider, orgId); + + sutProvider.GetDependency() + .GetOrganizationReportAsync(report.Id) + .Returns(report); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.DownloadReportFileAsync(orgId, report.Id)); + + await sutProvider.GetDependency() + .DidNotReceive() + .GetReportReadStreamAsync(Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task DownloadReportFileAsync_WithNullStream_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + OrganizationReport report) { // Arrange - request.OrganizationId = Guid.NewGuid(); // Different from orgId + var reportFile = new ReportFile { Id = "file-id", FileName = "report.json", Size = 1024, Validated = true }; + report.OrganizationId = orgId; + report.SetReportFile(reportFile); + + SetupAuthorization(sutProvider, orgId); + + sutProvider.GetDependency() + .GetOrganizationReportAsync(report.Id) + .Returns(report); + + sutProvider.GetDependency() + .GetReportReadStreamAsync(report, Arg.Any()) + .Returns((Stream)null); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.DownloadReportFileAsync(orgId, report.Id)); + } + [Theory, BitAutoData] + public async Task DownloadReportFileAsync_WithoutAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + Guid reportId) + { + // Arrange sutProvider.GetDependency() .AccessReports(orgId) - .Returns(true); + .Returns(false); // Act & Assert - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateOrganizationReportApplicationDataAsync(orgId, reportId, request)); - - Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); + await Assert.ThrowsAsync(() => + sutProvider.Sut.DownloadReportFileAsync(orgId, reportId)); - // Verify that the command was not called - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceive() - .UpdateOrganizationReportApplicationDataAsync(Arg.Any()); + .GetReportReadStreamAsync(Arg.Any(), Arg.Any()); } [Theory, BitAutoData] - public async Task UpdateOrganizationReportApplicationDataAsync_WithMismatchedReportId_ThrowsBadRequestException( + public async Task DownloadReportFileAsync_OrgMismatch_ThrowsBadRequestException( SutProvider sutProvider, Guid orgId, - Guid reportId, - UpdateOrganizationReportApplicationDataRequest request, - OrganizationReport updatedReport) + OrganizationReport report) { // Arrange - request.OrganizationId = orgId; + report.OrganizationId = Guid.NewGuid(); + SetupAuthorization(sutProvider, orgId); + + sutProvider.GetDependency() + .GetOrganizationReportAsync(report.Id) + .Returns(report); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.DownloadReportFileAsync(orgId, report.Id)); + + Assert.Equal("Invalid report ID", exception.Message); + } + + // UploadReportFileAsync + + [Theory, BitAutoData] + public async Task UploadReportFileAsync_WithoutAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + Guid reportId) + { + // Arrange sutProvider.GetDependency() .AccessReports(orgId) - .Returns(true); + .Returns(false); - sutProvider.GetDependency() - .UpdateOrganizationReportApplicationDataAsync(request) - .Returns(updatedReport); + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.UploadReportFileAsync(orgId, reportId, "file-id")); + } + + [Theory, BitAutoData] + public async Task UploadReportFileAsync_OrgMismatch_ThrowsBadRequestException( + SutProvider sutProvider, + Guid orgId, + OrganizationReport report) + { + // Arrange + report.OrganizationId = Guid.NewGuid(); + + SetupAuthorization(sutProvider, orgId); + + sutProvider.GetDependency() + .GetOrganizationReportAsync(report.Id) + .Returns(report); // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateOrganizationReportApplicationDataAsync(orgId, reportId, request)); + sutProvider.Sut.UploadReportFileAsync(orgId, report.Id, "file-id")); - Assert.Equal("Report ID in the request body must match the route parameter", exception.Message); + Assert.Equal("Invalid report ID", exception.Message); } [Theory, BitAutoData] - public async Task UpdateOrganizationReportApplicationDataAsync_CallsCorrectMethods( + public async Task UploadReportFileAsync_InvalidContentType_ThrowsBadRequestException( SutProvider sutProvider, Guid orgId, - Guid reportId, - UpdateOrganizationReportApplicationDataRequest request, - OrganizationReport expectedReport) + OrganizationReport report) + { + // Arrange — Request is null in SutProvider context, so ContentType check rejects + report.OrganizationId = orgId; + + SetupAuthorization(sutProvider, orgId); + + sutProvider.GetDependency() + .GetOrganizationReportAsync(report.Id) + .Returns(report); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.UploadReportFileAsync(orgId, report.Id, "file-id")); + + Assert.Equal("Invalid content.", exception.Message); + } + + // CreateOrganizationReportAsync - V2 file size cap + + [Theory, BitAutoData] + public async Task CreateOrganizationReportAsync_V2_FileSizeExceedsLimit_ThrowsBadRequestException( + SutProvider sutProvider, + Guid orgId, + AddOrganizationReportRequestModel request) { // Arrange - request.OrganizationId = orgId; - request.Id = reportId; - expectedReport.Id = reportId; + request.FileSize = Constants.FileSize501mb + 1; + SetupV2Authorization(sutProvider, orgId); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.CreateOrganizationReportAsync(orgId, request)); + + Assert.Equal("Max file size is 500 MB.", exception.Message); + + await sutProvider.GetDependency() + .DidNotReceive() + .CreateAsync(Arg.Any()); + } + + // Helper methods for authorization mocks + + private static void SetupAuthorization( + SutProvider sutProvider, + Guid orgId) + { sutProvider.GetDependency() .AccessReports(orgId) .Returns(true); - sutProvider.GetDependency() - .UpdateOrganizationReportApplicationDataAsync(request) - .Returns(expectedReport); + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(orgId) + .Returns(new OrganizationAbility { UseRiskInsights = true }); + } - // Act - await sutProvider.Sut.UpdateOrganizationReportApplicationDataAsync(orgId, reportId, request); + private static void SetupV2Authorization( + SutProvider sutProvider, + Guid orgId) + { + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(true); - // Assert - await sutProvider.GetDependency() - .Received(1) - .AccessReports(orgId); + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); - await sutProvider.GetDependency() - .Received(1) - .UpdateOrganizationReportApplicationDataAsync(request); + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(orgId) + .Returns(new OrganizationAbility { UseRiskInsights = true }); } - - #endregion } 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..f80e8282dd70 --- /dev/null +++ b/test/Core.Test/Dirt/ReportFeatures/CreateOrganizationReportCommandTests.cs @@ -0,0 +1,207 @@ +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_MissingSummaryData_ThrowsBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(r => r.ContentEncryptionKey, "test-key") + .With(r => r.SummaryData, 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("Summary Data is required", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task CreateAsync_MissingApplicationData_ThrowsBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(r => r.ContentEncryptionKey, "test-key") + .With(r => r.SummaryData, "summary") + .With(r => r.ApplicationData, 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("Application Data is required", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task CreateAsync_MissingReportMetrics_ThrowsBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(r => r.ContentEncryptionKey, "test-key") + .With(r => r.SummaryData, "summary") + .With(r => r.ApplicationData, "app-data") + .With(r => r.ReportMetrics, (OrganizationReportMetrics)null) + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(fixture.Create()); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + async () => await sutProvider.Sut.CreateAsync(request)); + Assert.Equal("Report Metrics 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..d4fcd1caf892 100644 --- a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportApplicationDataQueryTests.cs +++ b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportApplicationDataQueryTests.cs @@ -1,4 +1,5 @@ using AutoFixture; +using Bit.Core.Dirt.Entities; using Bit.Core.Dirt.Models.Data; using Bit.Core.Dirt.Reports.ReportFeatures; using Bit.Core.Dirt.Repositories; @@ -26,6 +27,16 @@ public async Task GetOrganizationReportApplicationDataAsync_WithValidParams_Shou var applicationDataResponse = fixture.Build() .Create(); + var report = fixture.Build() + .With(r => r.Id, reportId) + .With(r => r.OrganizationId, organizationId) + .Without(r => r.ReportFile) + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(reportId) + .Returns(report); + sutProvider.GetDependency() .GetApplicationDataAsync(reportId) .Returns(applicationDataResponse); @@ -42,11 +53,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 +68,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)); @@ -73,15 +80,72 @@ await sutProvider.GetDependency() .DidNotReceive().GetApplicationDataAsync(Arg.Any()); } + [Theory] + [BitAutoData] + public async Task GetOrganizationReportApplicationDataAsync_WhenReportNotFound_ShouldThrowNotFoundException( + SutProvider sutProvider) + { + // Arrange + var organizationId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + + sutProvider.GetDependency() + .GetByIdAsync(reportId) + .Returns((OrganizationReport)null); + + // Act & Assert + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetOrganizationReportApplicationDataAsync(organizationId, reportId)); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportApplicationDataAsync_WhenOrgMismatch_ShouldThrowNotFoundException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var organizationId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + + var report = fixture.Build() + .With(r => r.Id, reportId) + .With(r => r.OrganizationId, Guid.NewGuid()) // different org + .Without(r => r.ReportFile) + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(reportId) + .Returns(report); + + // Act & Assert + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetOrganizationReportApplicationDataAsync(organizationId, reportId)); + + await sutProvider.GetDependency() + .DidNotReceive().GetApplicationDataAsync(Arg.Any()); + } + [Theory] [BitAutoData] public async Task GetOrganizationReportApplicationDataAsync_WhenDataNotFound_ShouldThrowNotFoundException( SutProvider sutProvider) { // Arrange + var fixture = new Fixture(); var organizationId = Guid.NewGuid(); var reportId = Guid.NewGuid(); + var report = fixture.Build() + .With(r => r.Id, reportId) + .With(r => r.OrganizationId, organizationId) + .Without(r => r.ReportFile) + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(reportId) + .Returns(report); + sutProvider.GetDependency() .GetApplicationDataAsync(reportId) .Returns((OrganizationReportApplicationDataResponse)null); @@ -104,7 +168,7 @@ public async Task GetOrganizationReportApplicationDataAsync_WhenRepositoryThrows var expectedMessage = "Database connection failed"; sutProvider.GetDependency() - .GetApplicationDataAsync(reportId) + .GetByIdAsync(reportId) .Throws(new InvalidOperationException(expectedMessage)); // Act & Assert diff --git a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportDataQueryTests.cs b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportDataQueryTests.cs deleted file mode 100644 index 3c00c6870abe..000000000000 --- a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportDataQueryTests.cs +++ /dev/null @@ -1,116 +0,0 @@ -using AutoFixture; -using Bit.Core.Dirt.Models.Data; -using Bit.Core.Dirt.Reports.ReportFeatures; -using Bit.Core.Dirt.Repositories; -using Bit.Core.Exceptions; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using NSubstitute; -using NSubstitute.ExceptionExtensions; -using Xunit; - -namespace Bit.Core.Test.Dirt.ReportFeatures; - -[SutProviderCustomize] -public class GetOrganizationReportDataQueryTests -{ - [Theory] - [BitAutoData] - public async Task GetOrganizationReportDataAsync_WithValidParams_ShouldReturnReportData( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var organizationId = fixture.Create(); - var reportId = fixture.Create(); - var reportDataResponse = fixture.Build() - .Create(); - - sutProvider.GetDependency() - .GetReportDataAsync(reportId) - .Returns(reportDataResponse); - - // Act - var result = await sutProvider.Sut.GetOrganizationReportDataAsync(organizationId, reportId); - - // Assert - Assert.NotNull(result); - await sutProvider.GetDependency() - .Received(1).GetReportDataAsync(reportId); - } - - [Theory] - [BitAutoData] - public async Task GetOrganizationReportDataAsync_WithEmptyOrganizationId_ShouldThrowBadRequestException( - SutProvider sutProvider) - { - // Arrange - var reportId = Guid.NewGuid(); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => - await sutProvider.Sut.GetOrganizationReportDataAsync(Guid.Empty, reportId)); - - Assert.Equal("OrganizationId is required.", exception.Message); - await sutProvider.GetDependency() - .DidNotReceive().GetReportDataAsync(Arg.Any()); - } - - [Theory] - [BitAutoData] - public async Task GetOrganizationReportDataAsync_WithEmptyReportId_ShouldThrowBadRequestException( - SutProvider sutProvider) - { - // Arrange - var organizationId = Guid.NewGuid(); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => - await sutProvider.Sut.GetOrganizationReportDataAsync(organizationId, Guid.Empty)); - - Assert.Equal("ReportId is required.", exception.Message); - await sutProvider.GetDependency() - .DidNotReceive().GetReportDataAsync(Arg.Any()); - } - - [Theory] - [BitAutoData] - public async Task GetOrganizationReportDataAsync_WhenDataNotFound_ShouldThrowNotFoundException( - SutProvider sutProvider) - { - // Arrange - var organizationId = Guid.NewGuid(); - var reportId = Guid.NewGuid(); - - sutProvider.GetDependency() - .GetReportDataAsync(reportId) - .Returns((OrganizationReportDataResponse)null); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => - await sutProvider.Sut.GetOrganizationReportDataAsync(organizationId, reportId)); - - Assert.Equal("Organization report data not found.", exception.Message); - } - - [Theory] - [BitAutoData] - public async Task GetOrganizationReportDataAsync_WhenRepositoryThrowsException_ShouldPropagateException( - SutProvider sutProvider) - { - // Arrange - var organizationId = Guid.NewGuid(); - var reportId = Guid.NewGuid(); - var expectedMessage = "Database connection failed"; - - sutProvider.GetDependency() - .GetReportDataAsync(reportId) - .Throws(new InvalidOperationException(expectedMessage)); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => - await sutProvider.Sut.GetOrganizationReportDataAsync(organizationId, reportId)); - - Assert.Equal(expectedMessage, exception.Message); - } -} diff --git a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportSummaryDataQueryTests.cs b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportSummaryDataQueryTests.cs index c6ede1fcab21..6bbdd468fbb4 100644 --- a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportSummaryDataQueryTests.cs +++ b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportSummaryDataQueryTests.cs @@ -1,4 +1,5 @@ using AutoFixture; +using Bit.Core.Dirt.Entities; using Bit.Core.Dirt.Models.Data; using Bit.Core.Dirt.Reports.ReportFeatures; using Bit.Core.Dirt.Repositories; @@ -26,6 +27,16 @@ public async Task GetOrganizationReportSummaryDataAsync_WithValidParams_ShouldRe var summaryDataResponse = fixture.Build() .Create(); + var report = fixture.Build() + .With(r => r.Id, reportId) + .With(r => r.OrganizationId, organizationId) + .Without(r => r.ReportFile) + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(reportId) + .Returns(report); + sutProvider.GetDependency() .GetSummaryDataAsync(reportId) .Returns(summaryDataResponse); @@ -73,15 +84,72 @@ await sutProvider.GetDependency() .DidNotReceive().GetSummaryDataAsync(Arg.Any()); } + [Theory] + [BitAutoData] + public async Task GetOrganizationReportSummaryDataAsync_WhenReportNotFound_ShouldThrowNotFoundException( + SutProvider sutProvider) + { + // Arrange + var organizationId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + + sutProvider.GetDependency() + .GetByIdAsync(reportId) + .Returns((OrganizationReport)null); + + // Act & Assert + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetOrganizationReportSummaryDataAsync(organizationId, reportId)); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportSummaryDataAsync_WhenOrgMismatch_ShouldThrowNotFoundException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var organizationId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + + var report = fixture.Build() + .With(r => r.Id, reportId) + .With(r => r.OrganizationId, Guid.NewGuid()) // different org + .Without(r => r.ReportFile) + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(reportId) + .Returns(report); + + // Act & Assert + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetOrganizationReportSummaryDataAsync(organizationId, reportId)); + + await sutProvider.GetDependency() + .DidNotReceive().GetSummaryDataAsync(Arg.Any()); + } + [Theory] [BitAutoData] public async Task GetOrganizationReportSummaryDataAsync_WhenDataNotFound_ShouldThrowNotFoundException( SutProvider sutProvider) { // Arrange + var fixture = new Fixture(); var organizationId = Guid.NewGuid(); var reportId = Guid.NewGuid(); + var report = fixture.Build() + .With(r => r.Id, reportId) + .With(r => r.OrganizationId, organizationId) + .Without(r => r.ReportFile) + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(reportId) + .Returns(report); + sutProvider.GetDependency() .GetSummaryDataAsync(reportId) .Returns((OrganizationReportSummaryDataResponse)null); @@ -104,7 +172,7 @@ public async Task GetOrganizationReportSummaryDataAsync_WhenRepositoryThrowsExce var expectedMessage = "Database connection failed"; sutProvider.GetDependency() - .GetSummaryDataAsync(reportId) + .GetByIdAsync(reportId) .Throws(new InvalidOperationException(expectedMessage)); // Act & Assert diff --git a/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportCommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportCommandTests.cs index 1a4b8a51e66b..be84d36f8d43 100644 --- a/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportCommandTests.cs +++ b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportCommandTests.cs @@ -89,7 +89,7 @@ public async Task UpdateOrganizationReportAsync_WithEmptyOrganizationId_ShouldTh var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.UpdateOrganizationReportAsync(request)); - Assert.Equal("OrganizationId is required", exception.Message); + Assert.Equal("Invalid Organization", exception.Message); await sutProvider.GetDependency() .DidNotReceive().UpsertAsync(Arg.Any()); } @@ -109,7 +109,7 @@ public async Task UpdateOrganizationReportAsync_WithEmptyReportId_ShouldThrowBad var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.UpdateOrganizationReportAsync(request)); - Assert.Equal("ReportId is required", exception.Message); + Assert.Equal("Invalid Organization", exception.Message); await sutProvider.GetDependency() .DidNotReceive().UpsertAsync(Arg.Any()); } diff --git a/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportDataCommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportDataCommandTests.cs deleted file mode 100644 index 02cd74cbf635..000000000000 --- a/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportDataCommandTests.cs +++ /dev/null @@ -1,252 +0,0 @@ -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 NSubstitute.ExceptionExtensions; -using Xunit; - -namespace Bit.Core.Test.Dirt.ReportFeatures; - -[SutProviderCustomize] -public class UpdateOrganizationReportDataCommandTests -{ - [Theory] - [BitAutoData] - public async Task UpdateOrganizationReportDataAsync_WithValidRequest_ShouldReturnUpdatedReport( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var request = fixture.Build() - .With(x => x.ReportId, Guid.NewGuid()) - .With(x => x.OrganizationId, Guid.NewGuid()) - .With(x => x.ReportData, "updated report data") - .Create(); - - var organization = fixture.Create(); - var existingReport = fixture.Build() - .With(x => x.Id, request.ReportId) - .With(x => x.OrganizationId, request.OrganizationId) - .Create(); - var updatedReport = fixture.Build() - .With(x => x.Id, request.ReportId) - .With(x => x.OrganizationId, request.OrganizationId) - .Create(); - - sutProvider.GetDependency() - .GetByIdAsync(request.OrganizationId) - .Returns(organization); - sutProvider.GetDependency() - .GetByIdAsync(request.ReportId) - .Returns(existingReport); - sutProvider.GetDependency() - .UpdateReportDataAsync(request.OrganizationId, request.ReportId, request.ReportData) - .Returns(updatedReport); - - // Act - var result = await sutProvider.Sut.UpdateOrganizationReportDataAsync(request); - - // Assert - Assert.NotNull(result); - Assert.Equal(updatedReport.Id, result.Id); - Assert.Equal(updatedReport.OrganizationId, result.OrganizationId); - await sutProvider.GetDependency() - .Received(1).UpdateReportDataAsync(request.OrganizationId, request.ReportId, request.ReportData); - } - - [Theory] - [BitAutoData] - public async Task UpdateOrganizationReportDataAsync_WithEmptyOrganizationId_ShouldThrowBadRequestException( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var request = fixture.Build() - .With(x => x.OrganizationId, Guid.Empty) - .Create(); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => - await sutProvider.Sut.UpdateOrganizationReportDataAsync(request)); - - Assert.Equal("OrganizationId is required", exception.Message); - await sutProvider.GetDependency() - .DidNotReceive().UpdateReportDataAsync(Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Theory] - [BitAutoData] - public async Task UpdateOrganizationReportDataAsync_WithEmptyReportId_ShouldThrowBadRequestException( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var request = fixture.Build() - .With(x => x.ReportId, Guid.Empty) - .Create(); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => - await sutProvider.Sut.UpdateOrganizationReportDataAsync(request)); - - Assert.Equal("ReportId is required", exception.Message); - await sutProvider.GetDependency() - .DidNotReceive().UpdateReportDataAsync(Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Theory] - [BitAutoData] - public async Task UpdateOrganizationReportDataAsync_WithInvalidOrganization_ShouldThrowBadRequestException( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var request = fixture.Create(); - - sutProvider.GetDependency() - .GetByIdAsync(request.OrganizationId) - .Returns((Organization)null); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => - await sutProvider.Sut.UpdateOrganizationReportDataAsync(request)); - - Assert.Equal("Invalid Organization", exception.Message); - } - - [Theory] - [BitAutoData] - public async Task UpdateOrganizationReportDataAsync_WithEmptyReportData_ShouldThrowBadRequestException( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var request = fixture.Build() - .With(x => x.ReportData, string.Empty) - .Create(); - - var organization = fixture.Create(); - sutProvider.GetDependency() - .GetByIdAsync(request.OrganizationId) - .Returns(organization); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => - await sutProvider.Sut.UpdateOrganizationReportDataAsync(request)); - - Assert.Equal("Report Data is required", exception.Message); - } - - [Theory] - [BitAutoData] - public async Task UpdateOrganizationReportDataAsync_WithNullReportData_ShouldThrowBadRequestException( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var request = fixture.Build() - .With(x => x.ReportData, (string)null) - .Create(); - - var organization = fixture.Create(); - sutProvider.GetDependency() - .GetByIdAsync(request.OrganizationId) - .Returns(organization); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => - await sutProvider.Sut.UpdateOrganizationReportDataAsync(request)); - - Assert.Equal("Report Data is required", exception.Message); - } - - [Theory] - [BitAutoData] - public async Task UpdateOrganizationReportDataAsync_WithNonExistentReport_ShouldThrowNotFoundException( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var request = fixture.Create(); - var organization = fixture.Create(); - - sutProvider.GetDependency() - .GetByIdAsync(request.OrganizationId) - .Returns(organization); - sutProvider.GetDependency() - .GetByIdAsync(request.ReportId) - .Returns((OrganizationReport)null); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => - await sutProvider.Sut.UpdateOrganizationReportDataAsync(request)); - - Assert.Equal("Organization report not found", exception.Message); - } - - [Theory] - [BitAutoData] - public async Task UpdateOrganizationReportDataAsync_WithMismatchedOrganizationId_ShouldThrowBadRequestException( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var request = fixture.Create(); - var organization = fixture.Create(); - var existingReport = fixture.Build() - .With(x => x.Id, request.ReportId) - .With(x => x.OrganizationId, Guid.NewGuid()) // Different org ID - .Create(); - - sutProvider.GetDependency() - .GetByIdAsync(request.OrganizationId) - .Returns(organization); - sutProvider.GetDependency() - .GetByIdAsync(request.ReportId) - .Returns(existingReport); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => - await sutProvider.Sut.UpdateOrganizationReportDataAsync(request)); - - Assert.Equal("Organization report does not belong to the specified organization", exception.Message); - } - - [Theory] - [BitAutoData] - public async Task UpdateOrganizationReportDataAsync_WhenRepositoryThrowsException_ShouldPropagateException( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var request = fixture.Create(); - var organization = fixture.Create(); - var existingReport = fixture.Build() - .With(x => x.Id, request.ReportId) - .With(x => x.OrganizationId, request.OrganizationId) - .Create(); - - sutProvider.GetDependency() - .GetByIdAsync(request.OrganizationId) - .Returns(organization); - sutProvider.GetDependency() - .GetByIdAsync(request.ReportId) - .Returns(existingReport); - sutProvider.GetDependency() - .UpdateReportDataAsync(request.OrganizationId, request.ReportId, request.ReportData) - .Throws(new InvalidOperationException("Database connection failed")); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => - await sutProvider.Sut.UpdateOrganizationReportDataAsync(request)); - - Assert.Equal("Database connection failed", exception.Message); - } -} diff --git a/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportV2CommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportV2CommandTests.cs new file mode 100644 index 000000000000..39220caedfbb --- /dev/null +++ b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportV2CommandTests.cs @@ -0,0 +1,323 @@ +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", + ReportMetrics = new OrganizationReportMetrics { ApplicationCount = 10 } + }; + + 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, + ContentEncryptionKey = "key", + SummaryData = "summary", + ApplicationData = "app-data", + ReportMetrics = metrics + }; + + 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_NullFields_PreservesExistingValues( + 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") + .With(r => r.ApplicationCount, 5) + .Without(r => r.ReportFile) + .Create(); + + var request = new UpdateOrganizationReportV2Request + { + ReportId = reportId, + OrganizationId = orgId, + ContentEncryptionKey = null, + SummaryData = null, + ApplicationData = null, + ReportMetrics = null + }; + + 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); + Assert.Equal(5, result.ApplicationCount); + } + + [Theory] + [BitAutoData] + public async Task UpdateAsync_PartialUpdate_OnlyUpdatesProvidedFields( + 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, + SummaryData = "updated-summary", + ContentEncryptionKey = null, + ApplicationData = null, + ReportMetrics = null + }; + + 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("updated-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(), + ContentEncryptionKey = "key", + SummaryData = "summary", + ApplicationData = "app-data", + ReportMetrics = new OrganizationReportMetrics() + }; + + 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(), + ContentEncryptionKey = "key", + SummaryData = "summary", + ApplicationData = "app-data", + ReportMetrics = new OrganizationReportMetrics() + }; + + 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("Invalid Organization", 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("Invalid Organization", 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..11fe61cb9e55 --- /dev/null +++ b/test/Core.Test/Dirt/ReportFeatures/ValidateOrganizationReportFileCommandTests.cs @@ -0,0 +1,204 @@ +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.Core.Utilities; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; +using ZiggyCreatures.Caching.Fusion; + +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()); + + await sutProvider.GetDependency() + .Received(1) + .RemoveByTagAsync( + OrganizationReportCacheConstants.BuildCacheTagForOrganizationReports(organizationId)); + } + + [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, 999999999L)); + + // 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()); + + await sutProvider.GetDependency() + .Received(1) + .RemoveByTagAsync( + OrganizationReportCacheConstants.BuildCacheTagForOrganizationReports(organizationId)); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_StorageError_DoesNotDelete( + SutProvider sutProvider) + { + // Arrange + var reportId = Guid.NewGuid(); + var organizationId = Guid.NewGuid(); + var fileId = "test-file-id-789"; + 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() + .DidNotReceive() + .DeleteReportFilesAsync(Arg.Any(), Arg.Any()); + + await sutProvider.GetDependency() + .DidNotReceive() + .DeleteAsync(Arg.Any()); + + await sutProvider.GetDependency() + .DidNotReceive() + .ReplaceAsync(Arg.Any()); + + await sutProvider.GetDependency() + .DidNotReceive() + .RemoveByTagAsync(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..ab92ffc5cfc9 --- /dev/null +++ b/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs @@ -0,0 +1,354 @@ +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"; + globalSettings.BaseServiceUri.Api = "https://localhost/api"; + return globalSettings; + } + + private static LocalOrganizationReportStorageService CreateSut( + Core.Settings.GlobalSettings? globalSettings = null) + { + return new LocalOrganizationReportStorageService( + globalSettings ?? GetGlobalSettings()); + } + + 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 sut = CreateSut(); + + // Act & Assert + Assert.Equal(FileUploadType.Direct, sut.FileUploadType); + } + + [Fact] + public async Task GetReportFileUploadUrlAsync_ReturnsApiEndpoint() + { + // Arrange + var fixture = new Fixture(); + var sut = CreateSut(); + + 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_ReturnsAuthenticatedEndpointUrl() + { + // Arrange + var fixture = new Fixture(); + var sut = CreateSut(); + + 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.Equal($"https://localhost/api/reports/organizations/{orgId}/{reportId}/file/download", url); + } + + [Fact] + public async Task GetReportReadStreamAsync_FileExists_ReturnsStream() + { + // 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"; + globalSettings.BaseServiceUri.Api = "https://localhost/api"; + + var sut = CreateSut(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("stream-test-file"); + var testData = "test report content"; + var uploadStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(testData)); + + try + { + // Upload first + await sut.UploadReportDataAsync(report, fileData, uploadStream); + + // Act + var readStream = await sut.GetReportReadStreamAsync(report, fileData); + + // Assert + Assert.NotNull(readStream); + using var reader = new StreamReader(readStream); + var content = await reader.ReadToEndAsync(); + Assert.Equal(testData, content); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + [Fact] + public async Task GetReportReadStreamAsync_FileDoesNotExist_ReturnsNull() + { + // 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"; + globalSettings.BaseServiceUri.Api = "https://localhost/api"; + + var sut = CreateSut(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 stream = await sut.GetReportReadStreamAsync(report, fileData); + + // Assert + Assert.Null(stream); + } + + [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"; + globalSettings.BaseServiceUri.Api = "https://localhost/api"; + + var sut = CreateSut(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"; + globalSettings.BaseServiceUri.Api = "https://localhost/api"; + + var sut = CreateSut(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"; + globalSettings.BaseServiceUri.Api = "https://localhost/api"; + + var sut = CreateSut(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"; + globalSettings.BaseServiceUri.Api = "https://localhost/api"; + + var sut = CreateSut(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); + } +} diff --git a/test/Infrastructure.EFIntegration.Test/Dirt/Repositories/OrganizationReportRepositoryTests.cs b/test/Infrastructure.EFIntegration.Test/Dirt/Repositories/OrganizationReportRepositoryTests.cs index 345e7366e5c1..d2db7fcbcc94 100644 --- a/test/Infrastructure.EFIntegration.Test/Dirt/Repositories/OrganizationReportRepositoryTests.cs +++ b/test/Infrastructure.EFIntegration.Test/Dirt/Repositories/OrganizationReportRepositoryTests.cs @@ -417,51 +417,6 @@ public async Task GetSummaryDataByDateRangeAsync_ForAllEFProviders_ShouldReturnF } } - [CiSkippedTheory, EfOrganizationReportAutoData] - public async Task GetReportDataAsync_ShouldReturnReportData( - OrganizationReportRepository sqlOrganizationReportRepo, - SqlRepo.OrganizationRepository sqlOrganizationRepo) - { - // Arrange - var fixture = new Fixture(); - var reportData = "Test report data"; - var (org, report) = await CreateOrganizationAndReportWithReportDataAsync( - sqlOrganizationRepo, sqlOrganizationReportRepo, reportData); - - // Act - var result = await sqlOrganizationReportRepo.GetReportDataAsync(report.Id); - - // Assert - Assert.NotNull(result); - Assert.Equal(reportData, result.ReportData); - } - - [CiSkippedTheory, EfOrganizationReportAutoData] - public async Task UpdateReportDataAsync_ShouldUpdateReportDataAndRevisionDate( - OrganizationReportRepository sqlOrganizationReportRepo, - SqlRepo.OrganizationRepository sqlOrganizationRepo) - { - // Arrange - var (org, report) = await CreateOrganizationAndReportAsync(sqlOrganizationRepo, sqlOrganizationReportRepo); - var newReportData = "Updated report data"; - var originalRevisionDate = report.RevisionDate; - - // Add a small delay to ensure revision date difference - await Task.Delay(100); - - // Act - var updatedReport = await sqlOrganizationReportRepo.UpdateReportDataAsync( - org.Id, report.Id, newReportData); - - // Assert - Assert.NotNull(updatedReport); - Assert.Equal(org.Id, updatedReport.OrganizationId); - Assert.Equal(report.Id, updatedReport.Id); - Assert.Equal(newReportData, updatedReport.ReportData); - Assert.True(updatedReport.RevisionDate >= originalRevisionDate, - $"Expected RevisionDate {updatedReport.RevisionDate} to be >= {originalRevisionDate}"); - } - [CiSkippedTheory, EfOrganizationReportAutoData] public async Task GetApplicationDataAsync_ShouldReturnApplicationData( OrganizationReportRepository sqlOrganizationReportRepo, @@ -522,22 +477,6 @@ public async Task GetSummaryDataAsync_WithNonExistentReport_ShouldReturnNull( Assert.Null(result); } - [CiSkippedTheory, EfOrganizationReportAutoData] - public async Task GetReportDataAsync_WithNonExistentReport_ShouldReturnNull( - OrganizationReportRepository sqlOrganizationReportRepo, - SqlRepo.OrganizationRepository sqlOrganizationRepo) - { - // Arrange - var (org, _) = await CreateOrganizationAndReportAsync(sqlOrganizationRepo, sqlOrganizationReportRepo); - var nonExistentReportId = Guid.NewGuid(); - - // Act - var result = await sqlOrganizationReportRepo.GetReportDataAsync(nonExistentReportId); - - // Assert - Assert.Null(result); - } - [CiSkippedTheory, EfOrganizationReportAutoData] public async Task GetApplicationDataAsync_WithNonExistentReport_ShouldReturnNull( OrganizationReportRepository sqlOrganizationReportRepo, @@ -637,25 +576,6 @@ public async Task UpdateMetricsAsync_ShouldUpdateMetricsCorrectly( return (organization, orgReportRecord); } - private async Task<(Organization, OrganizationReport)> CreateOrganizationAndReportWithReportDataAsync( - IOrganizationRepository orgRepo, - IOrganizationReportRepository orgReportRepo, - string reportData) - { - var fixture = new Fixture(); - var organization = fixture.Create(); - - var orgReportRecord = fixture.Build() - .With(x => x.OrganizationId, organization.Id) - .With(x => x.ReportData, reportData) - .Create(); - - organization = await orgRepo.CreateAsync(organization); - orgReportRecord = await orgReportRepo.CreateAsync(orgReportRecord); - - return (organization, orgReportRecord); - } - private async Task<(Organization, OrganizationReport)> CreateOrganizationAndReportWithApplicationDataAsync( IOrganizationRepository orgRepo, IOrganizationReportRepository orgReportRepo,