From b201593390746bbcaba7a86d836cce73f47edef8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 6 Sep 2026 14:53:38 +0000 Subject: [PATCH] fix(web): validate usage summary date range Surface: backend services (apps/web server routes and lib). The usage summary endpoint accepts a date range but does not validate it: an end date before the start date returns a confusing empty result instead of a 400. Reproduce first against the live dev backend with two requests (a valid range and an inverted one). If it already returns a clear 400, stop with the evidence and no code change. If it does not, return a 400 with a specific message, keep the existing response shape for valid ranges, add a test beside the existing route tests, and prove both paths live in the PR body. --- .../routers/usage-analytics-router.test.ts | 48 +++++++++++++++++++ .../src/routers/usage-analytics-schemas.ts | 14 +++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/apps/web/src/routers/usage-analytics-router.test.ts b/apps/web/src/routers/usage-analytics-router.test.ts index 33e98d1f92..dc706424b1 100644 --- a/apps/web/src/routers/usage-analytics-router.test.ts +++ b/apps/web/src/routers/usage-analytics-router.test.ts @@ -206,6 +206,36 @@ describe('usage analytics organization breakdown', () => { }); }); +describe('usage analytics date range validation', () => { + const invertedFilters = { + ...baseFilters, + startDate: '2026-06-05T00:00:00.000Z', + endDate: '2026-06-04T00:00:00.000Z', + }; + + it('rejects an end date before the start date on the endDate field', () => { + const result = UsageAnalyticsFiltersSchema.safeParse(invertedFilters); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]).toMatchObject({ + path: ['endDate'], + message: 'endDate must not be before startDate', + }); + } + }); + + it('keeps valid ranges accepted, including an empty single-instant window', () => { + expect(UsageAnalyticsFiltersSchema.safeParse(baseFilters).success).toBe(true); + expect( + UsageAnalyticsFiltersSchema.safeParse({ + ...baseFilters, + startDate: '2026-06-04T10:00:00.000Z', + endDate: '2026-06-04T10:00:00.000Z', + }).success + ).toBe(true); + }); +}); + describe('usage analytics procedures', () => { beforeEach(() => { jest.clearAllMocks(); @@ -315,6 +345,24 @@ describe('usage analytics procedures', () => { }); }); + it('rejects an inverted date range with a 400 carrying the specific message', async () => { + await expect( + caller().getSummary({ + ...baseFilters, + startDate: '2026-06-05T00:00:00.000Z', + endDate: '2026-06-04T00:00:00.000Z', + }) + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + cause: { + issues: [ + { code: 'custom', path: ['endDate'], message: 'endDate must not be before startDate' }, + ], + }, + }); + expect(mockExecuteSnowflakeStatement).not.toHaveBeenCalled(); + }); + it.each([ () => caller().getSummary(baseFilters), () => caller().getTimeseries({ ...baseFilters, metric: 'cost' }), diff --git a/apps/web/src/routers/usage-analytics-schemas.ts b/apps/web/src/routers/usage-analytics-schemas.ts index 0b9728ed05..8bda3bd09f 100644 --- a/apps/web/src/routers/usage-analytics-schemas.ts +++ b/apps/web/src/routers/usage-analytics-schemas.ts @@ -66,7 +66,19 @@ const FiltersShape = { excludedProjects: z.array(z.string()).optional(), } as const; -export const UsageAnalyticsFiltersSchema = z.object(FiltersShape); +export const UsageAnalyticsFiltersSchema = z + .object(FiltersShape) + .superRefine((filters, context) => { + // An inverted window cannot match any usage row, so it would otherwise + // return a silently empty result instead of a clear client error. + if (new Date(filters.endDate).getTime() < new Date(filters.startDate).getTime()) { + context.addIssue({ + code: 'custom', + path: ['endDate'], + message: 'endDate must not be before startDate', + }); + } + }); export type UsageAnalyticsFilters = z.infer; export const SummaryOutputSchema = z.object({