From 6190ae6c9fb5b17b12ec59ca10359ea9e8e73393 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 6 Jul 2026 20:07:02 -0400 Subject: [PATCH] fix(metrics): validate metrics date-range query params Add date-window validation to MetricsQuerySchema: reject unparseable from/to dates, inverted ranges (from after to), and windows larger than ~1 year. This removes the TODO in the metrics controller and makes both the summary and timeseries endpoints reject bad ranges with 400 instead of silently coercing Invalid Date into the query. Fix a pre-existing internal test that was named 'returns 400 for invalid query' but asserted 200 (it documented the missing validation). Add unit coverage for the schema. Addresses #17 (date-window validation). Broadening the timeseries beyond login success/failure remains open. --- src/controllers/internalMetrics.ts | 1 - src/schemas/internal.query.ts | 42 +++++++++++++++++--- tests/integration/internal/internal.spec.ts | 2 +- tests/unit/schemas/metricsQuery.spec.ts | 43 +++++++++++++++++++++ 4 files changed, 80 insertions(+), 8 deletions(-) create mode 100644 tests/unit/schemas/metricsQuery.spec.ts diff --git a/src/controllers/internalMetrics.ts b/src/controllers/internalMetrics.ts index b738744..27ebda6 100644 --- a/src/controllers/internalMetrics.ts +++ b/src/controllers/internalMetrics.ts @@ -47,7 +47,6 @@ export const getAuthEventSummary = async (req: Request, res: Response) => { return res.status(400).json({ message: 'Invalid query params' }); } - // TODO: need to parse these for valid time ranges const { from, to } = parsed.data; const where: WhereOptions = diff --git a/src/schemas/internal.query.ts b/src/schemas/internal.query.ts index bb4872e..3416250 100644 --- a/src/schemas/internal.query.ts +++ b/src/schemas/internal.query.ts @@ -26,9 +26,39 @@ export const AuthEventQuerySchema = z.object({ to: z.string().optional(), }); -export const MetricsQuerySchema = z.object({ - userId: z.string().optional(), - from: z.string().optional(), - to: z.string().optional(), - interval: z.enum(['hour', 'day']).optional().default('hour'), -}); +const MAX_METRICS_WINDOW_MS = 1000 * 60 * 60 * 24 * 366; // ~1 year + +export const MetricsQuerySchema = z + .object({ + userId: z.string().optional(), + from: z.string().optional(), + to: z.string().optional(), + interval: z.enum(['hour', 'day']).optional().default('hour'), + }) + .superRefine((data, ctx) => { + const fromDate = data.from ? new Date(data.from) : undefined; + const toDate = data.to ? new Date(data.to) : undefined; + + const fromValid = fromDate !== undefined && !Number.isNaN(fromDate.getTime()); + const toValid = toDate !== undefined && !Number.isNaN(toDate.getTime()); + + if (data.from !== undefined && !fromValid) { + ctx.addIssue({ code: 'custom', path: ['from'], message: 'Invalid from date' }); + } + + if (data.to !== undefined && !toValid) { + ctx.addIssue({ code: 'custom', path: ['to'], message: 'Invalid to date' }); + } + + if (fromValid && toValid) { + if (fromDate!.getTime() > toDate!.getTime()) { + ctx.addIssue({ code: 'custom', path: ['to'], message: 'from must be on or before to' }); + } else if (toDate!.getTime() - fromDate!.getTime() > MAX_METRICS_WINDOW_MS) { + ctx.addIssue({ + code: 'custom', + path: ['to'], + message: 'time range exceeds the maximum window', + }); + } + } + }); diff --git a/tests/integration/internal/internal.spec.ts b/tests/integration/internal/internal.spec.ts index cc41fc1..3eb50b7 100644 --- a/tests/integration/internal/internal.spec.ts +++ b/tests/integration/internal/internal.spec.ts @@ -49,7 +49,7 @@ describe('GET /internal/auth-events/summary', () => { it('returns 400 for invalid query', async () => { const res = await request(app).get('/internal/auth-events/summary?from=bad'); - expect(res.status).toBe(200); + expect(res.status).toBe(400); }); }); diff --git a/tests/unit/schemas/metricsQuery.spec.ts b/tests/unit/schemas/metricsQuery.spec.ts new file mode 100644 index 0000000..bac8d7e --- /dev/null +++ b/tests/unit/schemas/metricsQuery.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { MetricsQuerySchema } from '../../../src/schemas/internal.query.js'; + +describe('MetricsQuerySchema', () => { + it('accepts a valid range and defaults interval to hour', () => { + const parsed = MetricsQuerySchema.safeParse({ + from: '2026-01-01T00:00:00.000Z', + to: '2026-01-08T00:00:00.000Z', + }); + + expect(parsed.success).toBe(true); + expect(parsed.success && parsed.data.interval).toBe('hour'); + }); + + it('accepts an empty query', () => { + expect(MetricsQuerySchema.safeParse({}).success).toBe(true); + }); + + it('rejects an unparseable from date', () => { + const parsed = MetricsQuerySchema.safeParse({ from: 'not-a-date' }); + + expect(parsed.success).toBe(false); + }); + + it('rejects an inverted range (from after to)', () => { + const parsed = MetricsQuerySchema.safeParse({ + from: '2026-02-01T00:00:00.000Z', + to: '2026-01-01T00:00:00.000Z', + }); + + expect(parsed.success).toBe(false); + }); + + it('rejects a window larger than the maximum', () => { + const parsed = MetricsQuerySchema.safeParse({ + from: '2020-01-01T00:00:00.000Z', + to: '2026-01-01T00:00:00.000Z', + }); + + expect(parsed.success).toBe(false); + }); +});