diff --git a/src/Api/Dirt/Public/Controllers/EventsController.cs b/src/Api/Dirt/Public/Controllers/EventsController.cs index b7ace39503ea..f699b9631d2c 100644 --- a/src/Api/Dirt/Public/Controllers/EventsController.cs +++ b/src/Api/Dirt/Public/Controllers/EventsController.cs @@ -51,7 +51,9 @@ public EventsController( /// /// /// Returns a filtered list of your organization's event logs, paged by a continuation token. - /// If no filters are provided, it will return the last 30 days of event for the organization. + /// If no date filters are provided, it will return the last 30 days of events for the organization. + /// Providing only start returns events from then through the current time; + /// providing only end returns the 30 days before it. A range greater than 367 days is rejected. /// [HttpGet] [ProducesResponseType(typeof(PagedListResponseModel), (int)HttpStatusCode.OK)] diff --git a/src/Api/Dirt/Public/Models/EventFilterRequestModel.cs b/src/Api/Dirt/Public/Models/EventFilterRequestModel.cs index 20984c2cb078..4e24545e5945 100644 --- a/src/Api/Dirt/Public/Models/EventFilterRequestModel.cs +++ b/src/Api/Dirt/Public/Models/EventFilterRequestModel.cs @@ -1,18 +1,18 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable -using Bit.Core.Exceptions; +using Bit.Api.Utilities; namespace Bit.Api.Dirt.Public.Models; public class EventFilterRequestModel { /// - /// The start date. Must be less than the end date. + /// The start date. If omitted, defaults to 30 days before the end date (or 30 days ago when no end date is given). /// public DateTime? Start { get; set; } /// - /// The end date. Must be greater than the start date. + /// The end date. If omitted, defaults to the current time. /// public DateTime? End { get; set; } /// @@ -38,23 +38,9 @@ public class EventFilterRequestModel public Tuple ToDateRange() { - if (!End.HasValue || !Start.HasValue) - { - End = DateTime.UtcNow.Date.AddDays(1).AddMilliseconds(-1); - Start = DateTime.UtcNow.Date.AddDays(-30); - } - else if (Start.Value > End.Value) - { - var newEnd = Start; - Start = End; - End = newEnd; - } - - if ((End.Value - Start.Value) > TimeSpan.FromDays(367)) - { - throw new BadRequestException("Date range must be < 367 days."); - } - - return new Tuple(Start.Value, End.Value); + var dateRange = ApiHelpers.GetDateRange(Start, End); + Start = dateRange.Item1; + End = dateRange.Item2; + return dateRange; } } diff --git a/src/Api/Utilities/ApiHelpers.cs b/src/Api/Utilities/ApiHelpers.cs index 3c0701b1bd47..9d166fcf92bd 100644 --- a/src/Api/Utilities/ApiHelpers.cs +++ b/src/Api/Utilities/ApiHelpers.cs @@ -80,28 +80,34 @@ public async static Task HandleAzureEvents(HttpRequest request, /// start date and time /// end date and time /// - /// If start or end are null, will return a range of the last 30 days. - /// If a time span greater than 367 days is passed will throw BadRequestException. + /// A supplied bound is always honored; the missing bound is inferred from it. + /// With neither supplied, returns the last 30 days. + /// With only , the range runs to the current time. + /// With only , the range covers the 30 days before it. + /// An inverted range is swapped. A range greater than 367 days throws BadRequestException. /// public static Tuple GetDateRange(DateTime? start, DateTime? end) { - if (!end.HasValue || !start.HasValue) + if (!start.HasValue) { - end = DateTime.UtcNow.Date.AddDays(1).AddMilliseconds(-1); - start = DateTime.UtcNow.Date.AddDays(-30); + start = end.HasValue ? ThirtyDaysBefore(end.Value) : DateTime.UtcNow.Date.AddDays(-30); } - else if (start.Value > end.Value) + + end ??= DateTime.UtcNow; + + if (start.Value > end.Value) { - var newEnd = start; - start = end; - end = newEnd; + (start, end) = (end, start); } if ((end.Value - start.Value) > TimeSpan.FromDays(367)) { - throw new BadRequestException("Range too large."); + throw new BadRequestException("Date range must be < 367 days."); } return new Tuple(start.Value, end.Value); } + + private static DateTime ThirtyDaysBefore(DateTime value) => + value - DateTime.MinValue < TimeSpan.FromDays(30) ? DateTime.MinValue : value.AddDays(-30); } diff --git a/test/Api.Test/Dirt/Public/Models/EventFilterRequestModelTests.cs b/test/Api.Test/Dirt/Public/Models/EventFilterRequestModelTests.cs new file mode 100644 index 000000000000..eb5be70a101c --- /dev/null +++ b/test/Api.Test/Dirt/Public/Models/EventFilterRequestModelTests.cs @@ -0,0 +1,29 @@ +using Bit.Api.Dirt.Public.Models; +using Xunit; + +namespace Bit.Api.Test.Dirt.Public.Models; + +public class EventFilterRequestModelTests +{ + [Fact] + public void ToDateRange_OnlyStartSupplied_DoesNotFallBackToThirtyDayDefault() + { + var suppliedStart = DateTime.UtcNow.AddDays(-3); + var request = new EventFilterRequestModel { Start = suppliedStart }; + + var dateRange = request.ToDateRange(); + + Assert.Equal(suppliedStart, dateRange.Item1); + } + + [Fact] + public void ToDateRange_WritesResolvedBoundsBackOntoTheModelForDiagnosticLogging() + { + var request = new EventFilterRequestModel(); + + var dateRange = request.ToDateRange(); + + Assert.Equal(dateRange.Item1, request.Start); + Assert.Equal(dateRange.Item2, request.End); + } +} diff --git a/test/Api.Test/Utilities/ApiHelpersTests.cs b/test/Api.Test/Utilities/ApiHelpersTests.cs index ec8f10ca6b73..1eea4acb0801 100644 --- a/test/Api.Test/Utilities/ApiHelpersTests.cs +++ b/test/Api.Test/Utilities/ApiHelpersTests.cs @@ -1,6 +1,7 @@ using System.Text; using Bit.Api.Utilities; using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Exceptions; using Microsoft.AspNetCore.Http; using NSubstitute; using Xunit; @@ -22,5 +23,121 @@ public async Task ReadJsonFileFromBody_Success() Assert.Equal(8, license.Version); } + [Fact] + public void GetDateRange_NeitherBoundSupplied_ReturnsLastThirtyDays() + { + var before = DateTime.UtcNow; + + var (start, end) = ApiHelpers.GetDateRange(null, null); + + Assert.Equal(DateTime.UtcNow.Date.AddDays(-30), start); + Assert.InRange(end, before, DateTime.UtcNow); + } + + [Fact] + public void GetDateRange_OnlyStartSupplied_KeepsStartAndRunsToNow() + { + var suppliedStart = DateTime.UtcNow.AddDays(-3); + var before = DateTime.UtcNow; + + var (start, end) = ApiHelpers.GetDateRange(suppliedStart, null); + + Assert.Equal(suppliedStart, start); + Assert.InRange(end, before, DateTime.UtcNow); + } + + [Fact] + public void GetDateRange_OnlyEndSupplied_KeepsEndAndStartsThirtyDaysBefore() + { + var suppliedEnd = new DateTime(2026, 6, 8, 14, 9, 35, DateTimeKind.Utc); + + var (start, end) = ApiHelpers.GetDateRange(null, suppliedEnd); + + Assert.Equal(suppliedEnd.AddDays(-30), start); + Assert.Equal(suppliedEnd, end); + } + + [Fact] + public void GetDateRange_OnlyEndSuppliedNearMinValue_ClampsStartInsteadOfThrowing() + { + var suppliedEnd = new DateTime(1, 1, 10, 0, 0, 0, DateTimeKind.Utc); + + var (start, end) = ApiHelpers.GetDateRange(null, suppliedEnd); + + Assert.Equal(DateTime.MinValue, start); + Assert.Equal(suppliedEnd, end); + } + + [Fact] + public void GetDateRange_OnlyEndSuppliedExactlyThirtyDaysAfterMinValue_ClampsToMinValue() + { + var suppliedEnd = DateTime.MinValue.AddDays(30); + + var (start, _) = ApiHelpers.GetDateRange(null, suppliedEnd); + + Assert.Equal(DateTime.MinValue, start); + } + + [Fact] + public void GetDateRange_OnlyStartSuppliedAtMaxValue_ThrowsBadRequestRatherThanOverflowing() + { + Assert.Throws(() => ApiHelpers.GetDateRange(DateTime.MaxValue, null)); + } + + [Fact] + public void GetDateRange_BothBoundsSupplied_ReturnsThemUnchanged() + { + var suppliedStart = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc); + var suppliedEnd = new DateTime(2026, 6, 8, 0, 0, 0, DateTimeKind.Utc); + + var (start, end) = ApiHelpers.GetDateRange(suppliedStart, suppliedEnd); + + Assert.Equal(suppliedStart, start); + Assert.Equal(suppliedEnd, end); + } + + [Fact] + public void GetDateRange_InvertedBounds_SwapsThem() + { + var earlier = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc); + var later = new DateTime(2026, 6, 8, 0, 0, 0, DateTimeKind.Utc); + + var (start, end) = ApiHelpers.GetDateRange(later, earlier); + + Assert.Equal(earlier, start); + Assert.Equal(later, end); + } + + [Fact] + public void GetDateRange_RangeExceedsCap_ThrowsBadRequest() + { + var suppliedStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var suppliedEnd = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + var exception = Assert.Throws( + () => ApiHelpers.GetDateRange(suppliedStart, suppliedEnd)); + + Assert.Equal("Date range must be < 367 days.", exception.Message); + } + + [Fact] + public void GetDateRange_OnlyStartSuppliedBeyondCap_ThrowsBadRequest() + { + var suppliedStart = DateTime.UtcNow.AddDays(-400); + + Assert.Throws(() => ApiHelpers.GetDateRange(suppliedStart, null)); + } + + [Fact] + public void GetDateRange_OnlyStartSuppliedInTheFuture_SwapsRatherThanInverting() + { + var suppliedStart = DateTime.UtcNow.AddDays(3); + + var (start, end) = ApiHelpers.GetDateRange(suppliedStart, null); + + Assert.True(start <= end); + Assert.Equal(suppliedStart, end); + } + const string testFile = "{\"licenseKey\": \"licenseKey\", \"installationId\": \"6285f891-b2ec-4047-84c5-2eb7f7747e74\", \"id\": \"1065216d-5854-4326-838d-635487f30b43\",\"name\": \"Test Org\",\"billingEmail\": \"test@email.com\",\"businessName\": null,\"enabled\": true, \"plan\": \"Enterprise (Annually)\",\"planType\": 11,\"seats\": 6,\"maxCollections\": null,\"usePolicies\": true,\"useSso\": true,\"useKeyConnector\": false,\"useGroups\": true,\"useEvents\": true,\"useDirectory\": true,\"useTotp\": true,\"use2fa\": true,\"useApi\": true,\"useResetPassword\": true,\"maxStorageGb\": 1,\"selfHost\": true,\"usersGetPremium\": true,\"version\": 8,\"issued\": \"2022-01-25T21:58:38.9454581Z\",\"refresh\": \"2022-01-28T14:26:31Z\",\"expires\": \"2022-01-28T14:26:31Z\",\"trial\": true,\"hash\": \"testvalue\",\"signature\": \"signature\"}"; }