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); + }); +});