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\"}";
}