🛠️ fix: Inclusive to date filter and reject inverted ranges

A `?to=2025-01-15` filter previously stopped at midnight UTC of that
day, silently excluding everything that happened on January 15. The
`parseIsoDate` helper now widens a bare `YYYY-MM-DD` to 23:59:59.999Z
when called with the `end` boundary. Full ISO timestamps are honored
exactly, so callers that want minute-precision can still get it.

Also rejects inverted ranges (`from` later than `to`) with a 400 so
operators see a clear error instead of a silent empty result.
This commit is contained in:
Dustin Healy 2026-05-14 14:57:14 -07:00 • committed by Danny Avila
parent d55a014af7
commit ddc69efb04
2 changed files with 58 additions and 3 deletions

View file

@ -184,6 +184,43 @@ describe('createAdminAuditLogHandlers', () => {
expect(status).toHaveBeenCalledWith(200);
});
it('widens a date-only `to` filter to the end of the day UTC', async () => {
const deps = createDeps();
const handlers = createAdminAuditLogHandlers(deps);
const { req, res } = createReqRes({ query: { to: '2025-01-15' } });
await handlers.listAuditLog(req, res);
const filtersArg = (deps.listAuditLogPage as jest.Mock).mock.calls[0][1];
expect(filtersArg.to).toBeInstanceOf(Date);
expect((filtersArg.to as Date).toISOString()).toBe('2025-01-15T23:59:59.999Z');
});
it('leaves a full ISO `to` timestamp exact (no day-boundary widening)', async () => {
const deps = createDeps();
const handlers = createAdminAuditLogHandlers(deps);
const { req, res } = createReqRes({ query: { to: '2025-01-15T08:30:00Z' } });
await handlers.listAuditLog(req, res);
const filtersArg = (deps.listAuditLogPage as jest.Mock).mock.calls[0][1];
expect((filtersArg.to as Date).toISOString()).toBe('2025-01-15T08:30:00.000Z');
});
it('rejects when `from` is later than `to`', async () => {
const deps = createDeps();
const handlers = createAdminAuditLogHandlers(deps);
const { req, res, status, json } = createReqRes({
query: { from: '2025-12-31', to: '2025-01-01' },
});
await handlers.listAuditLog(req, res);
expect(status).toHaveBeenCalledWith(400);
expect(json.mock.calls[0][0].error).toMatch(/from.*to/);
expect(deps.listAuditLogPage).not.toHaveBeenCalled();
});
it('rejects limit > 500 with 400', async () => {
const deps = createDeps();
const handlers = createAdminAuditLogHandlers(deps);

View file

@ -77,12 +77,27 @@ function parseActionFilter(raw: unknown):
return { ok: true, value: arr as AuditAction[] };
}
function parseIsoDate(raw: unknown): { ok: true; value?: Date } | { ok: false; error: string } {
const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
/**
* Parses a filter date. `boundary` controls how a bare `YYYY-MM-DD` is widened:
* `start` leaves it at the beginning of the day (00:00:00.000Z, the default for
* `from`), `end` snaps it to 23:59:59.999Z so that a `to=2025-01-15` filter
* actually includes everything that occurred on January 15 instead of cutting
* off at midnight. Full ISO timestamps are honored exactly regardless.
*/
function parseIsoDate(
raw: unknown,
boundary: 'start' | 'end' = 'start',
): { ok: true; value?: Date } | { ok: false; error: string } {
if (raw == null || raw === '') return { ok: true, value: undefined };
if (typeof raw !== 'string') return { ok: false, error: 'Date must be a string' };
if (!ISO_DATE_RE.test(raw)) return { ok: false, error: 'Date must be ISO 8601' };
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return { ok: false, error: 'Invalid date' };
if (boundary === 'end' && DATE_ONLY_RE.test(raw)) {
d.setUTCHours(23, 59, 59, 999);
}
return { ok: true, value: d };
}
@ -208,10 +223,13 @@ function readTargetQuery(query: AuditLogQuery): string | undefined {
function parseFilters(
query: AuditLogQuery,
): { ok: true; value: ParsedFilters } | { ok: false; error: string } {
const from = parseIsoDate(query.from);
const from = parseIsoDate(query.from, 'start');
if (!from.ok) return { ok: false, error: `from: ${from.error}` };
const to = parseIsoDate(query.to);
const to = parseIsoDate(query.to, 'end');
if (!to.ok) return { ok: false, error: `to: ${to.error}` };
if (from.value && to.value && from.value > to.value) {
return { ok: false, error: '`from` must be earlier than `to`' };
}
const action = parseActionFilter(query.action);
if (!action.ok) return { ok: false, error: action.error };
const targetPrincipalType = parsePrincipalType(query.targetPrincipalType);