diff --git a/src/Api/Dirt/Controllers/OrganizationReportsController.cs b/src/Api/Dirt/Controllers/OrganizationReportsController.cs index fc9a1b2d84a0..fc76efc07107 100644 --- a/src/Api/Dirt/Controllers/OrganizationReportsController.cs +++ b/src/Api/Dirt/Controllers/OrganizationReportsController.cs @@ -1,5 +1,6 @@ using Bit.Api.Dirt.Models.Response; using Bit.Core.Context; +using Bit.Core.Dirt.Models.Data; using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; using Bit.Core.Dirt.Reports.ReportFeatures.Requests; using Bit.Core.Exceptions; @@ -130,7 +131,22 @@ public async Task UpdateOrganizationReportAsync(Guid organization # region SummaryData Field Endpoints + /// + /// Gets summary data for organization reports within a specified date range. + /// The response is optimized for widget display by returning up to 6 entries that are + /// evenly spaced across the date range, including the most recent entry. + /// This allows the widget to show trends over time while ensuring the latest data point is always included. + /// + /// + /// + /// + /// + /// + /// [HttpGet("{organizationId}/data/summary")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task GetOrganizationReportSummaryDataByDateRangeAsync( Guid organizationId, [FromQuery] DateTime startDate, [FromQuery] DateTime endDate) { @@ -139,7 +155,7 @@ public async Task GetOrganizationReportSummaryDataByDateRangeAsyn throw new NotFoundException(); } - if (organizationId.Equals(null)) + if (organizationId == Guid.Empty) { throw new BadRequestException("Organization ID is required."); } diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 7ac9c2813950..44398cfc726c 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -185,7 +185,7 @@ public void ConfigureServices(IServiceCollection services) services.AddOrganizationSubscriptionServices(); services.AddCoreLocalizationServices(); services.AddBillingOperations(); - services.AddReportingServices(); + services.AddReportingServices(globalSettings); services.AddImportServices(); services.AddSendServices(); diff --git a/src/Core/Dirt/Models/Data/OrganizationReportSummaryDataResponse.cs b/src/Core/Dirt/Models/Data/OrganizationReportSummaryDataResponse.cs index 0533c2862f79..5c4765db4656 100644 --- a/src/Core/Dirt/Models/Data/OrganizationReportSummaryDataResponse.cs +++ b/src/Core/Dirt/Models/Data/OrganizationReportSummaryDataResponse.cs @@ -1,6 +1,14 @@ -namespace Bit.Core.Dirt.Models.Data; +using System.Text.Json.Serialization; + +namespace Bit.Core.Dirt.Models.Data; public class OrganizationReportSummaryDataResponse { - public string? SummaryData { get; set; } + public required Guid OrganizationId { get; set; } + [JsonPropertyName("encryptedData")] + public required string SummaryData { get; set; } + [JsonPropertyName("encryptionKey")] + public required string ContentEncryptionKey { get; set; } + [JsonPropertyName("date")] + public required DateTime RevisionDate { get; set; } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/AddOrganizationReportCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/AddOrganizationReportCommand.cs index 236560487e92..7c2dc66f604c 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/AddOrganizationReportCommand.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/AddOrganizationReportCommand.cs @@ -4,7 +4,10 @@ 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; @@ -12,15 +15,18 @@ public class AddOrganizationReportCommand : IAddOrganizationReportCommand { private readonly IOrganizationRepository _organizationRepo; private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly IFusionCache _cache; private ILogger _logger; public AddOrganizationReportCommand( IOrganizationRepository organizationRepository, IOrganizationReportRepository organizationReportRepository, + [FromKeyedServices(OrganizationReportCacheConstants.CacheName)] IFusionCache cache, ILogger logger) { _organizationRepo = organizationRepository; _organizationReportRepo = organizationReportRepository; + _cache = cache; _logger = logger; } @@ -64,6 +70,8 @@ public async Task AddOrganizationReportAsync(AddOrganization var data = await _organizationReportRepo.CreateAsync(organizationReport); + await _cache.RemoveByTagAsync(OrganizationReportCacheConstants.BuildCacheTagForOrganizationReports(request.OrganizationId)); + _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully added organization report for organization {organizationId}, {organizationReportId}", request.OrganizationId, data.Id); diff --git a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportSummaryDataByDateRangeQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportSummaryDataByDateRangeQuery.cs index 7be59b822ee5..6d2d80bb3e66 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportSummaryDataByDateRangeQuery.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportSummaryDataByDateRangeQuery.cs @@ -2,20 +2,27 @@ using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; using Bit.Core.Dirt.Repositories; using Bit.Core.Exceptions; +using Bit.Core.Utilities; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using ZiggyCreatures.Caching.Fusion; namespace Bit.Core.Dirt.Reports.ReportFeatures; public class GetOrganizationReportSummaryDataByDateRangeQuery : IGetOrganizationReportSummaryDataByDateRangeQuery { + private const int MaxRecordsForWidget = 6; private readonly IOrganizationReportRepository _organizationReportRepo; private readonly ILogger _logger; + private readonly IFusionCache _cache; public GetOrganizationReportSummaryDataByDateRangeQuery( IOrganizationReportRepository organizationReportRepo, + [FromKeyedServices(OrganizationReportCacheConstants.CacheName)] IFusionCache cache, ILogger logger) { _organizationReportRepo = organizationReportRepo; + _cache = cache; _logger = logger; } @@ -23,7 +30,7 @@ public async Task> GetOrganiz { try { - _logger.LogInformation(Constants.BypassFiltersEventId, "Fetching organization report summary data by date range for organization {organizationId}, from {startDate} to {endDate}", + _logger.LogInformation(Constants.BypassFiltersEventId, "Fetching organization report summary data by date range for organization {OrganizationId}, from {StartDate} to {EndDate}", organizationId, startDate, endDate); var (isValid, errorMessage) = ValidateRequest(organizationId, startDate, endDate); @@ -33,30 +40,35 @@ public async Task> GetOrganiz throw new BadRequestException(errorMessage); } - IEnumerable summaryDataList = (await _organizationReportRepo - .GetSummaryDataByDateRangeAsync(organizationId, startDate, endDate)) ?? - Enumerable.Empty(); + // update start and end date to include the entire day + startDate = startDate.Date; + endDate = endDate.Date.AddDays(1).AddTicks(-1); - var resultList = summaryDataList.ToList(); + // cache key and tag + var cacheKey = OrganizationReportCacheConstants.BuildCacheKeyForSummaryDataByDateRange(organizationId, startDate, endDate); + var cacheTag = OrganizationReportCacheConstants.BuildCacheTagForOrganizationReports(organizationId); - if (!resultList.Any()) - { - _logger.LogInformation(Constants.BypassFiltersEventId, "No summary data found for organization {organizationId} in date range {startDate} to {endDate}", - organizationId, startDate, endDate); - return Enumerable.Empty(); - } - else - { - _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully retrieved {count} organization report summary data records for organization {organizationId} in date range {startDate} to {endDate}", - resultList.Count, organizationId, startDate, endDate); + var summaryDataList = await _cache.GetOrSetAsync( + key: cacheKey, + factory: async _ => + { + var data = await _organizationReportRepo.GetSummaryDataByDateRangeAsync(organizationId, startDate, endDate); + return GetMostRecentEntries(data); + }, + options: new FusionCacheEntryOptions(duration: OrganizationReportCacheConstants.DurationForSummaryData), + tags: [cacheTag] + ); - } + var resultList = summaryDataList?.ToList() ?? Enumerable.Empty().ToList(); + + _logger.LogInformation(Constants.BypassFiltersEventId, "Fetched {Count} organization report summary data entries for organization {OrganizationId}, from {StartDate} to {EndDate}", + resultList.Count, organizationId, startDate, endDate); return resultList; } catch (Exception ex) when (!(ex is BadRequestException)) { - _logger.LogError(ex, "Error fetching organization report summary data by date range for organization {organizationId}, from {startDate} to {endDate}", + _logger.LogError(ex, "Error fetching organization report summary data by date range for organization {OrganizationId}, from {StartDate} to {EndDate}", organizationId, startDate, endDate); throw; } @@ -86,4 +98,27 @@ private static (bool IsValid, string errorMessage) ValidateRequest(Guid organiza return (true, string.Empty); } + + private static IEnumerable GetMostRecentEntries(IEnumerable data, int maxEntries = MaxRecordsForWidget) + { + if (data.Count() <= maxEntries) + { + return data; + } + + // here we need to take 10 records, evenly spaced by RevisionDate, + // to cover the entire date range, + // and ensure we include the most recent record as well + var sortedData = data.OrderByDescending(d => d.RevisionDate).ToList(); + var totalRecords = sortedData.Count; + var interval = (double)(totalRecords - 1) / (maxEntries - 1); // -1 the most recent record will be included by default + var result = new List(); + + for (int i = 0; i <= maxEntries - 1; i++) + { + result.Add(sortedData[(int)Math.Round(i * interval)]); + } + + return result; + } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs b/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs index f89ff977624f..fbbc6967395a 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs @@ -1,13 +1,17 @@ using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; using Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces; +using Bit.Core.Settings; +using Bit.Core.Utilities; using Microsoft.Extensions.DependencyInjection; namespace Bit.Core.Dirt.Reports.ReportFeatures; public static class ReportingServiceCollectionExtensions { - public static void AddReportingServices(this IServiceCollection services) + public static void AddReportingServices(this IServiceCollection services, IGlobalSettings globalSettings) { + services.AddExtendedCache(OrganizationReportCacheConstants.CacheName, (GlobalSettings)globalSettings); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportCommand.cs index 7fb77030a85a..32b1815ebeea 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportCommand.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportCommand.cs @@ -4,7 +4,10 @@ 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; @@ -13,15 +16,17 @@ public class UpdateOrganizationReportCommand : IUpdateOrganizationReportCommand private readonly IOrganizationRepository _organizationRepo; private readonly IOrganizationReportRepository _organizationReportRepo; private readonly ILogger _logger; - + private readonly IFusionCache _cache; public UpdateOrganizationReportCommand( IOrganizationRepository organizationRepository, IOrganizationReportRepository organizationReportRepository, - ILogger logger) + ILogger logger, + [FromKeyedServices(OrganizationReportCacheConstants.CacheName)] IFusionCache cache) { _organizationRepo = organizationRepository; _organizationReportRepo = organizationReportRepository; _logger = logger; + _cache = cache; } public async Task UpdateOrganizationReportAsync(UpdateOrganizationReportRequest request) @@ -61,6 +66,9 @@ public async Task UpdateOrganizationReportAsync(UpdateOrgani await _organizationReportRepo.UpsertAsync(existingReport); + // Invalidate cache + await _cache.RemoveByTagAsync(OrganizationReportCacheConstants.BuildCacheTagForOrganizationReports(request.OrganizationId)); + _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully updated organization report {reportId} for organization {organizationId}", request.ReportId, request.OrganizationId); diff --git a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportSummaryCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportSummaryCommand.cs index 5d0f2670ca76..a0e6c56a0fbc 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportSummaryCommand.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportSummaryCommand.cs @@ -5,7 +5,10 @@ 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; @@ -14,15 +17,17 @@ public class UpdateOrganizationReportSummaryCommand : IUpdateOrganizationReportS private readonly IOrganizationRepository _organizationRepo; private readonly IOrganizationReportRepository _organizationReportRepo; private readonly ILogger _logger; - + private readonly IFusionCache _cache; public UpdateOrganizationReportSummaryCommand( IOrganizationRepository organizationRepository, IOrganizationReportRepository organizationReportRepository, - ILogger logger) + ILogger logger, + [FromKeyedServices(OrganizationReportCacheConstants.CacheName)] IFusionCache cache) { _organizationRepo = organizationRepository; _organizationReportRepo = organizationReportRepository; _logger = logger; + _cache = cache; } public async Task UpdateOrganizationReportSummaryAsync(UpdateOrganizationReportSummaryRequest request) @@ -57,6 +62,9 @@ public async Task UpdateOrganizationReportSummaryAsync(Updat await _organizationReportRepo.UpdateMetricsAsync(request.ReportId, OrganizationReportMetricsData.From(request.OrganizationId, request.Metrics)); var updatedReport = await _organizationReportRepo.UpdateSummaryDataAsync(request.OrganizationId, request.ReportId, request.SummaryData ?? string.Empty); + // Invalidate cache + await _cache.RemoveByTagAsync(OrganizationReportCacheConstants.BuildCacheTagForOrganizationReports(request.OrganizationId)); + _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully updated organization report summary {reportId} for organization {organizationId}", request.ReportId, request.OrganizationId); diff --git a/src/Core/Utilities/OrganizationReportCacheConstants.cs b/src/Core/Utilities/OrganizationReportCacheConstants.cs new file mode 100644 index 000000000000..575bf79b83f6 --- /dev/null +++ b/src/Core/Utilities/OrganizationReportCacheConstants.cs @@ -0,0 +1,44 @@ +namespace Bit.Core.Utilities; + +/// +/// Provides cache key generation helpers and cache name constants for organization report–related entities. +/// +public static class OrganizationReportCacheConstants +{ + /// + /// The cache name used for storing organization report data. + /// + public const string CacheName = "OrganizationReports"; + + /// + /// Duration TimeSpan for caching organization report summary data. + /// Consider: Reports might be regenerated daily, so cache for shorter periods. + /// + public static readonly TimeSpan DurationForSummaryData = TimeSpan.FromHours(6); + + /// + /// Builds a deterministic cache key for organization report summary data by date range. + /// + /// The unique identifier of the organization. + /// The start date of the date range. + /// The end date of the date range. + /// + /// A cache key for the organization report summary data. + /// + public static string BuildCacheKeyForSummaryDataByDateRange( + Guid organizationId, + DateTime startDate, + DateTime endDate) + => $"OrganizationReportSummaryData:{organizationId:N}:{startDate:yyyy-MM-dd}:{endDate:yyyy-MM-dd}"; + + /// + /// Builds a cache tag for an organization's report data. + /// Used for bulk invalidation when organization reports are updated. + /// + /// The unique identifier of the organization. + /// + /// A cache tag for the organization's reports. + /// + public static string BuildCacheTagForOrganizationReports(Guid organizationId) + => $"OrganizationReports:{organizationId:N}"; +} diff --git a/src/Infrastructure.Dapper/Dirt/OrganizationReportRepository.cs b/src/Infrastructure.Dapper/Dirt/OrganizationReportRepository.cs index c704a208d170..0472efaac192 100644 --- a/src/Infrastructure.Dapper/Dirt/OrganizationReportRepository.cs +++ b/src/Infrastructure.Dapper/Dirt/OrganizationReportRepository.cs @@ -86,12 +86,12 @@ public async Task> GetSummary var parameters = new { OrganizationId = organizationId, - StartDate = startDate, - EndDate = endDate + StartDate = startDate.ToUniversalTime(), + EndDate = endDate.ToUniversalTime() }; var results = await connection.QueryAsync( - $"[{Schema}].[OrganizationReport_GetSummariesByDateRange]", + $"[{Schema}].[OrganizationReport_ReadByOrganizationIdAndRevisionDate]", parameters, commandType: CommandType.StoredProcedure); diff --git a/src/Infrastructure.EntityFramework/Dirt/Repositories/OrganizationReportRepository.cs b/src/Infrastructure.EntityFramework/Dirt/Repositories/OrganizationReportRepository.cs index d08e70c35325..c06519f12a16 100644 --- a/src/Infrastructure.EntityFramework/Dirt/Repositories/OrganizationReportRepository.cs +++ b/src/Infrastructure.EntityFramework/Dirt/Repositories/OrganizationReportRepository.cs @@ -72,7 +72,10 @@ public async Task GetSummaryDataAsync(Gui .Where(p => p.Id == reportId) .Select(p => new OrganizationReportSummaryDataResponse { - SummaryData = p.SummaryData + OrganizationId = p.OrganizationId, + ContentEncryptionKey = p.ContentEncryptionKey, + SummaryData = p.SummaryData, + RevisionDate = p.RevisionDate }) .FirstOrDefaultAsync(); @@ -91,10 +94,13 @@ public async Task> GetSummary var results = await dbContext.OrganizationReports .Where(p => p.OrganizationId == organizationId && - p.CreationDate >= startDate && p.CreationDate <= endDate) + p.RevisionDate >= startDate && p.RevisionDate <= endDate) .Select(p => new OrganizationReportSummaryDataResponse { - SummaryData = p.SummaryData + OrganizationId = p.OrganizationId, + ContentEncryptionKey = p.ContentEncryptionKey, + SummaryData = p.SummaryData, + RevisionDate = p.RevisionDate }) .ToListAsync(); diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 42f5d9e65573..f0f63eda62f8 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -170,7 +170,7 @@ public static void AddBaseServices(this IServiceCollection services, IGlobalSett services.AddScoped(); services.AddScoped(); services.AddVaultServices(); - services.AddReportingServices(); + services.AddReportingServices(globalSettings); services.AddKeyManagementServices(); services.AddNotificationCenterServices(); services.AddPlatformServices(); diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetSummariesByDateRange.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_ReadByOrganizationIdAndRevisionDate.sql similarity index 64% rename from src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetSummariesByDateRange.sql rename to src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_ReadByOrganizationIdAndRevisionDate.sql index 2ab78a2a1e75..dce868d97928 100644 --- a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetSummariesByDateRange.sql +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_ReadByOrganizationIdAndRevisionDate.sql @@ -1,4 +1,4 @@ -CREATE PROCEDURE [dbo].[OrganizationReport_GetSummariesByDateRange] +CREATE PROCEDURE [dbo].[OrganizationReport_ReadByOrganizationIdAndRevisionDate] @OrganizationId UNIQUEIDENTIFIER, @StartDate DATETIME2(7), @EndDate DATETIME2(7) @@ -7,11 +7,14 @@ BEGIN SET NOCOUNT ON SELECT - [SummaryData] + [OrganizationId], + [ContentEncryptionKey], + [SummaryData], + [RevisionDate] FROM [dbo].[OrganizationReportView] WHERE [OrganizationId] = @OrganizationId AND [RevisionDate] >= @StartDate AND [RevisionDate] <= @EndDate ORDER BY [RevisionDate] DESC END - +GO diff --git a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportSummaryDataByDateRangeQueryTests.cs b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportSummaryDataByDateRangeQueryTests.cs index 572b7e21fb48..9cf965fdd97d 100644 --- a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportSummaryDataByDateRangeQueryTests.cs +++ b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportSummaryDataByDateRangeQueryTests.cs @@ -8,6 +8,7 @@ using NSubstitute; using NSubstitute.ExceptionExtensions; using Xunit; +using ZiggyCreatures.Caching.Fusion; namespace Bit.Core.Test.Dirt.ReportFeatures; @@ -26,12 +27,29 @@ public async Task GetOrganizationReportSummaryDataByDateRangeAsync_WithValidPara var startDate = DateTime.UtcNow.AddDays(-30); var endDate = DateTime.UtcNow; var summaryDataList = fixture.Build() - .CreateMany(3); + .CreateMany(3).ToList(); + summaryDataList[0].RevisionDate = DateTime.UtcNow; // most recent + summaryDataList[1].RevisionDate = DateTime.UtcNow.AddDays(-1); + summaryDataList[2].RevisionDate = DateTime.UtcNow.AddDays(-2); sutProvider.GetDependency() - .GetSummaryDataByDateRangeAsync(organizationId, startDate, endDate) + .GetSummaryDataByDateRangeAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(summaryDataList); + sutProvider + .GetDependency() + .GetOrSetAsync( + key: Arg.Any(), + factory: Arg.Any>>>(), + options: Arg.Any(), + tags: Arg.Any>()) + .Returns(callInfo => + { + var factory = callInfo.ArgAt>, CancellationToken, Task>>>(1); + return new ValueTask>(factory.Invoke(null, CancellationToken.None)); + }); + + // Act var result = await sutProvider.Sut.GetOrganizationReportSummaryDataByDateRangeAsync(organizationId, startDate, endDate); @@ -39,9 +57,65 @@ public async Task GetOrganizationReportSummaryDataByDateRangeAsync_WithValidPara Assert.NotNull(result); Assert.Equal(3, result.Count()); await sutProvider.GetDependency() - .Received(1).GetSummaryDataByDateRangeAsync(organizationId, startDate, endDate); + .Received(1).GetSummaryDataByDateRangeAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportSummaryDataByDateRangeAsync_ShouldReturnTopSixResults( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var organizationId = fixture.Create(); + var reportId = fixture.Create(); + var startDate = DateTime.UtcNow.AddDays(-30); + var endDate = DateTime.UtcNow; + var summaryDataList = fixture.Build() + .CreateMany(12) + .ToList(); + summaryDataList[0].RevisionDate = DateTime.UtcNow; // most recent + summaryDataList[1].RevisionDate = DateTime.UtcNow.AddDays(-1); + summaryDataList[2].RevisionDate = DateTime.UtcNow.AddDays(-2); + summaryDataList[3].RevisionDate = DateTime.UtcNow.AddDays(-3); + summaryDataList[4].RevisionDate = DateTime.UtcNow.AddDays(-4); + summaryDataList[5].RevisionDate = DateTime.UtcNow.AddDays(-5); + summaryDataList[6].RevisionDate = DateTime.UtcNow.AddDays(-6); + summaryDataList[7].RevisionDate = DateTime.UtcNow.AddDays(-7); + summaryDataList[8].RevisionDate = DateTime.UtcNow.AddDays(-8); + summaryDataList[9].RevisionDate = DateTime.UtcNow.AddDays(-9); + summaryDataList[10].RevisionDate = DateTime.UtcNow.AddDays(-10); + summaryDataList[11].RevisionDate = DateTime.UtcNow.AddDays(-11); + + sutProvider.GetDependency() + .GetSummaryDataByDateRangeAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(summaryDataList); + + sutProvider + .GetDependency() + .GetOrSetAsync( + key: Arg.Any(), + factory: Arg.Any>>>(), + options: Arg.Any(), + tags: Arg.Any>()) + .Returns(callInfo => + { + var factory = callInfo.ArgAt>, CancellationToken, Task>>>(1); + return new ValueTask>(factory.Invoke(null, CancellationToken.None)); + }); + + + // Act + var result = await sutProvider.Sut.GetOrganizationReportSummaryDataByDateRangeAsync(organizationId, startDate, endDate); + + // Assert + Assert.NotNull(result); + Assert.Equal(6, result.Count()); + await sutProvider.GetDependency() + .Received(1).GetSummaryDataByDateRangeAsync(Arg.Any(), Arg.Any(), Arg.Any()); } + [Theory] [BitAutoData] public async Task GetOrganizationReportSummaryDataByDateRangeAsync_WithEmptyOrganizationId_ShouldThrowBadRequestException( @@ -100,6 +174,20 @@ public async Task GetOrganizationReportSummaryDataByDateRangeAsync_WithEmptyResu .GetSummaryDataByDateRangeAsync(organizationId, startDate, endDate) .Returns(new List()); + sutProvider + .GetDependency() + .GetOrSetAsync( + key: Arg.Any(), + factory: Arg.Any>>>(), + options: Arg.Any(), + tags: Arg.Any>()) + .Returns(callInfo => + { + var factory = callInfo.ArgAt>, CancellationToken, Task>>>(1); + return new ValueTask>(factory.Invoke(null, CancellationToken.None)); + }); + + // Act var result = await sutProvider.Sut.GetOrganizationReportSummaryDataByDateRangeAsync(organizationId, startDate, endDate); @@ -120,14 +208,36 @@ public async Task GetOrganizationReportSummaryDataByDateRangeAsync_WhenRepositor var endDate = DateTime.UtcNow; var expectedMessage = "Database connection failed"; - sutProvider.GetDependency() + var repo = sutProvider.GetDependency(); + + repo .GetSummaryDataByDateRangeAsync(organizationId, startDate, endDate) .Throws(new InvalidOperationException(expectedMessage)); + sutProvider + .GetDependency() + .GetOrSetAsync( + key: Arg.Any(), + factory: Arg.Any>>>(), + options: Arg.Any(), + tags: Arg.Any>()) + .Returns(callInfo => + { + var factory = callInfo.ArgAt>, CancellationToken, Task>>>(1); + return new ValueTask>(factory.Invoke(null, CancellationToken.None)); + }); + + // Act & Assert - var exception = await Assert.ThrowsAsync(async () => - await sutProvider.Sut.GetOrganizationReportSummaryDataByDateRangeAsync(organizationId, startDate, endDate)); + // var exception = await Assert.ThrowsAsync(async () => + // await sutProvider.Sut.GetOrganizationReportSummaryDataByDateRangeAsync(organizationId, startDate, endDate)); - Assert.Equal(expectedMessage, exception.Message); + var results = await sutProvider.Sut.GetOrganizationReportSummaryDataByDateRangeAsync(organizationId, startDate, endDate); + + // Assert + // since the IFusionCache has a failsafe, + // the exception from the repository should be caught and logged, and an empty list should be returned + Assert.NotNull(results); + Assert.Empty(results); } } diff --git a/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportCommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportCommandTests.cs index 3a84eb0d8008..1a4b8a51e66b 100644 --- a/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportCommandTests.cs +++ b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportCommandTests.cs @@ -6,10 +6,12 @@ using Bit.Core.Dirt.Repositories; using Bit.Core.Exceptions; using Bit.Core.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; @@ -68,6 +70,8 @@ await sutProvider.GetDependency() .Received(2).GetByIdAsync(request.ReportId); await sutProvider.GetDependency() .Received(1).UpsertAsync(Arg.Any()); + await sutProvider.GetDependency().Received(1) + .RemoveByTagAsync(OrganizationReportCacheConstants.BuildCacheTagForOrganizationReports(request.OrganizationId)); } [Theory] diff --git a/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportSummaryCommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportSummaryCommandTests.cs index dae3ff35bae2..59507a63b862 100644 --- a/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportSummaryCommandTests.cs +++ b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportSummaryCommandTests.cs @@ -6,11 +6,13 @@ using Bit.Core.Dirt.Repositories; using Bit.Core.Exceptions; using Bit.Core.Repositories; +using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using NSubstitute.ExceptionExtensions; using Xunit; +using ZiggyCreatures.Caching.Fusion; namespace Bit.Core.Test.Dirt.ReportFeatures; @@ -59,6 +61,8 @@ public async Task UpdateOrganizationReportSummaryAsync_WithValidRequest_ShouldRe Assert.Equal(updatedReport.OrganizationId, result.OrganizationId); await sutProvider.GetDependency() .Received(1).UpdateSummaryDataAsync(request.OrganizationId, request.ReportId, request.SummaryData); + await sutProvider.GetDependency().Received(1) + .RemoveByTagAsync(OrganizationReportCacheConstants.BuildCacheTagForOrganizationReports(request.OrganizationId)); } [Theory] diff --git a/test/Infrastructure.EFIntegration.Test/Dirt/Repositories/OrganizationReportRepositoryTests.cs b/test/Infrastructure.EFIntegration.Test/Dirt/Repositories/OrganizationReportRepositoryTests.cs index f2613fd2417b..345e7366e5c1 100644 --- a/test/Infrastructure.EFIntegration.Test/Dirt/Repositories/OrganizationReportRepositoryTests.cs +++ b/test/Infrastructure.EFIntegration.Test/Dirt/Repositories/OrganizationReportRepositoryTests.cs @@ -1,6 +1,7 @@ using AutoFixture; using Bit.Core.AdminConsole.Entities; using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Models.Data; using Bit.Core.Dirt.Reports.Models.Data; using Bit.Core.Dirt.Repositories; using Bit.Core.Repositories; @@ -353,6 +354,69 @@ public async Task GetSummaryDataByDateRangeAsync_ShouldReturnFilteredResults( Assert.All(resultsList, r => Assert.NotNull(r.SummaryData)); } + [CiSkippedTheory, EfOrganizationReportAutoData] + public async Task GetSummaryDataByDateRangeAsync_ForAllEFProviders_ShouldReturnFilteredResults( + Organization organization, + List suts, + List efOrganizationRepos) + { + // Arrange + var baseDate = DateTime.UtcNow; + var startDate = baseDate.AddDays(-10); + var endDate = baseDate.AddDays(1); + var fixture = new Fixture(); + var responses = new List>(); + + foreach (var sut in suts) + { + var index = suts.IndexOf(sut); + + // Create organization first + var org = await efOrganizationRepos[index].CreateAsync(organization); + + // Create first report with a date within range + var report1 = fixture.Build() + .With(x => x.OrganizationId, org.Id) + .With(x => x.SummaryData, "Summary 1") + .With(x => x.CreationDate, baseDate.AddDays(-5)) // Within range + .With(x => x.RevisionDate, baseDate.AddDays(-5)) + .Create(); + await sut.CreateAsync(report1); + + // Create second report with a date within range + var report2 = fixture.Build() + .With(x => x.OrganizationId, org.Id) + .With(x => x.SummaryData, "Summary 2") + .With(x => x.CreationDate, baseDate.AddDays(-3)) // within range + .With(x => x.RevisionDate, baseDate.AddDays(-3)) + .Create(); + await sut.CreateAsync(report2); + + // Create third report with a date not within range + var report3 = fixture.Build() + .With(x => x.OrganizationId, org.Id) + .With(x => x.SummaryData, "Summary 3") + .With(x => x.CreationDate, baseDate.AddDays(-20)) // not in range + .With(x => x.RevisionDate, baseDate.AddDays(-20)) + .Create(); + await sut.CreateAsync(report3); + + // Act + var results = await sut.GetSummaryDataByDateRangeAsync(org.Id, startDate, endDate); + responses.Add(results); + } + + // Assert + Assert.NotNull(responses); + foreach (var results in responses) + { + var resultsList = results.ToList(); + Assert.True(resultsList.Count >= 2, $"Expected at least 2 results, but got {resultsList.Count}"); + Assert.All(resultsList, r => Assert.NotNull(r.SummaryData)); + Assert.All(resultsList, r => Assert.NotNull(r.ContentEncryptionKey)); + } + } + [CiSkippedTheory, EfOrganizationReportAutoData] public async Task GetReportDataAsync_ShouldReturnReportData( OrganizationReportRepository sqlOrganizationReportRepo, @@ -538,7 +602,10 @@ public async Task UpdateMetricsAsync_ShouldUpdateMetricsCorrectly( IOrganizationReportRepository orgReportRepo) { var fixture = new Fixture(); - var organization = fixture.Create(); + var organization = fixture.Build() + .With(x => x.CreationDate, DateTime.UtcNow) + .With(x => x.RevisionDate, DateTime.UtcNow) + .Create(); var orgReportRecord = fixture.Build() .With(x => x.OrganizationId, organization.Id) diff --git a/util/Migrator/DbScripts/2026-02-27_00_AddOrganizationReport_ReadByOrganizationIdAndRevisionDate.sql b/util/Migrator/DbScripts/2026-02-27_00_AddOrganizationReport_ReadByOrganizationIdAndRevisionDate.sql new file mode 100644 index 000000000000..48b0b1851c97 --- /dev/null +++ b/util/Migrator/DbScripts/2026-02-27_00_AddOrganizationReport_ReadByOrganizationIdAndRevisionDate.sql @@ -0,0 +1,23 @@ +DROP PROC IF EXISTS [dbo].[OrganizationReport_GetSummariesByDateRange] +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationReport_ReadByOrganizationIdAndRevisionDate] + @OrganizationId UNIQUEIDENTIFIER, + @StartDate DATETIME2(7), + @EndDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + SELECT + [OrganizationId], + [ContentEncryptionKey], + [SummaryData], + [RevisionDate] + FROM [dbo].[OrganizationReportView] + WHERE [OrganizationId] = @OrganizationId + AND [RevisionDate] >= @StartDate + AND [RevisionDate] <= @EndDate + ORDER BY [RevisionDate] DESC +END +GO