Skip to content

Commit 640258d

Browse files
Alix-007Peter Steinberger
andauthored
fix(usage): reject inverted startDate-endDate range in usage.cost and sessions.usage (#94096)
* fix(usage): reject inverted startDate-endDate range * chore: retrigger CI for real behavior proof check * refactor(usage): resolve validated date ranges once --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent 076da56 commit 640258d

2 files changed

Lines changed: 125 additions & 93 deletions

File tree

src/gateway/server-methods/usage.test.ts

Lines changed: 88 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -72,14 +72,23 @@ describe("gateway usage helpers", () => {
7272
});
7373

7474
function expectUtcDateRange(
75-
range: ReturnType<typeof testApi.parseDateRange>,
75+
result: ReturnType<typeof testApi.resolveDateRange>,
7676
startDate: string,
7777
endDate: string,
7878
) {
79+
const range = expectDateRange(result);
7980
expect(range.startMs).toBe(testApi.parseDateToMs(startDate));
8081
expect(range.endMs).toBe(testApi.parseDateToMs(endDate)! + dayMs - 1);
8182
}
8283

84+
function expectDateRange(result: ReturnType<typeof testApi.resolveDateRange>) {
85+
expect(result.ok).toBe(true);
86+
if (!result.ok) {
87+
throw new Error(result.error);
88+
}
89+
return result.value;
90+
}
91+
8392
beforeEach(() => {
8493
testApi.costUsageCache.clear();
8594
vi.useRealTimers();
@@ -106,21 +115,18 @@ describe("gateway usage helpers", () => {
106115
expect(testApi.parseDateToMs("2024-02-29")).toBe(Date.UTC(2024, 1, 29));
107116
});
108117

109-
it("findInvalidExplicitDate flags provided-but-unparseable dates and ignores absent/valid ones", () => {
110-
// Explicitly provided invalid dates (bad format or impossible calendar date) are reported.
111-
expect(testApi.findInvalidExplicitDate({ startDate: "2026-02-30" })).toBe("startDate");
112-
expect(testApi.findInvalidExplicitDate({ endDate: "2026-2-5" })).toBe("endDate");
113-
expect(testApi.findInvalidExplicitDate({ startDate: 0 })).toBe("startDate");
114-
expect(testApi.findInvalidExplicitDate({ endDate: [] })).toBe("endDate");
115-
expect(
116-
testApi.findInvalidExplicitDate({ startDate: "2026-02-01", endDate: "2026-13-01" }),
117-
).toBe("endDate");
118-
// Absent or valid dates are not flagged, so they still fall through to the default range.
119-
expect(testApi.findInvalidExplicitDate({})).toBeUndefined();
120-
expect(testApi.findInvalidExplicitDate({ startDate: "", endDate: null })).toBeUndefined();
121-
expect(
122-
testApi.findInvalidExplicitDate({ startDate: "2026-02-01", endDate: "2026-02-02" }),
123-
).toBeUndefined();
118+
it.each([
119+
[{ startDate: "2026-02-30" }, "invalid startDate"],
120+
[{ endDate: "2026-2-5" }, "invalid endDate"],
121+
[{ startDate: 0 }, "invalid startDate"],
122+
[{ endDate: [] }, "invalid endDate"],
123+
[{ startDate: "2026-02-01", endDate: "2026-13-01" }, "invalid endDate"],
124+
[{ startDate: "2026-02-03", endDate: "2026-02-02" }, "startDate must not be after endDate"],
125+
])("resolveDateRange rejects invalid explicit ranges", (params, error) => {
126+
expect(testApi.resolveDateRange(params)).toEqual({
127+
ok: false,
128+
error: expect.stringContaining(error),
129+
});
124130
});
125131

126132
it("usage.cost rejects an explicitly provided invalid date with INVALID_REQUEST", async () => {
@@ -139,6 +145,25 @@ describe("gateway usage helpers", () => {
139145
expect(vi.mocked(loadCostUsageSummaryFromCache)).not.toHaveBeenCalled();
140146
});
141147

148+
it.each(["usage.cost", "sessions.usage"] as const)(
149+
"%s rejects startDate after endDate with INVALID_REQUEST",
150+
async (method) => {
151+
const respond = vi.fn();
152+
await usageHandlers[method]({
153+
respond,
154+
params: { startDate: "2026-02-03", endDate: "2026-02-02" },
155+
context: { getRuntimeConfig: vi.fn(() => ({})) },
156+
} as unknown as Parameters<(typeof usageHandlers)[typeof method]>[0]);
157+
158+
expect(respond).toHaveBeenCalledTimes(1);
159+
const [ok, payload, error] = respond.mock.calls[0];
160+
expect(ok).toBe(false);
161+
expect(payload).toBeUndefined();
162+
expect(JSON.stringify(error)).toContain("startDate must not be after endDate");
163+
expect(vi.mocked(loadCostUsageSummaryFromCache)).not.toHaveBeenCalled();
164+
},
165+
);
166+
142167
it("parseUtcOffsetToMinutes supports whole-hour and half-hour offsets", () => {
143168
expect(testApi.parseUtcOffsetToMinutes("UTC-4")).toBe(-240);
144169
expect(testApi.parseUtcOffsetToMinutes("UTC+5:30")).toBe(330);
@@ -160,80 +185,91 @@ describe("gateway usage helpers", () => {
160185
expect(testApi.parseDays("nope")).toBeUndefined();
161186
});
162187

163-
it("parseDateRange uses explicit start/end as UTC when mode is missing (backward compatible)", () => {
164-
const range = testApi.parseDateRange({ startDate: "2026-02-01", endDate: "2026-02-02" });
165-
expectUtcDateRange(range, "2026-02-01", "2026-02-02");
166-
});
167-
168-
it("parseDateRange uses explicit UTC mode", () => {
169-
const range = testApi.parseDateRange({
188+
it("resolveDateRange uses explicit start/end as UTC when mode is missing (backward compatible)", () => {
189+
const result = testApi.resolveDateRange({
170190
startDate: "2026-02-01",
171191
endDate: "2026-02-02",
172-
mode: "utc",
173192
});
174-
expectUtcDateRange(range, "2026-02-01", "2026-02-02");
193+
expectUtcDateRange(result, "2026-02-01", "2026-02-02");
175194
});
176195

177-
it("parseDateRange uses specific UTC offset for explicit dates", () => {
178-
const range = testApi.parseDateRange({
196+
it("resolveDateRange uses explicit UTC mode", () => {
197+
const result = testApi.resolveDateRange({
179198
startDate: "2026-02-01",
180199
endDate: "2026-02-02",
181-
mode: "specific",
182-
utcOffset: "UTC+5:30",
200+
mode: "utc",
183201
});
202+
expectUtcDateRange(result, "2026-02-01", "2026-02-02");
203+
});
204+
205+
it("resolveDateRange uses specific UTC offset for explicit dates", () => {
206+
const range = expectDateRange(
207+
testApi.resolveDateRange({
208+
startDate: "2026-02-01",
209+
endDate: "2026-02-02",
210+
mode: "specific",
211+
utcOffset: "UTC+5:30",
212+
}),
213+
);
184214
const start = Date.UTC(2026, 1, 1) - 5.5 * 60 * 60 * 1000;
185215
const endStart = Date.UTC(2026, 1, 2) - 5.5 * 60 * 60 * 1000;
186216
expect(range.startMs).toBe(start);
187217
expect(range.endMs).toBe(endStart + dayMs - 1);
188218
});
189219

190-
it("parseDateRange falls back to UTC when specific mode offset is missing or invalid", () => {
191-
const missingOffset = testApi.parseDateRange({
192-
startDate: "2026-02-01",
193-
endDate: "2026-02-02",
194-
mode: "specific",
195-
});
196-
const invalidOffset = testApi.parseDateRange({
197-
startDate: "2026-02-01",
198-
endDate: "2026-02-02",
199-
mode: "specific",
200-
utcOffset: "bad-value",
201-
});
220+
it("resolveDateRange falls back to UTC when specific mode offset is missing or invalid", () => {
221+
const missingOffset = expectDateRange(
222+
testApi.resolveDateRange({
223+
startDate: "2026-02-01",
224+
endDate: "2026-02-02",
225+
mode: "specific",
226+
}),
227+
);
228+
const invalidOffset = expectDateRange(
229+
testApi.resolveDateRange({
230+
startDate: "2026-02-01",
231+
endDate: "2026-02-02",
232+
mode: "specific",
233+
utcOffset: "bad-value",
234+
}),
235+
);
202236
expect(missingOffset.startMs).toBe(Date.UTC(2026, 1, 1));
203237
expect(missingOffset.endMs).toBe(Date.UTC(2026, 1, 2) + dayMs - 1);
204238
expect(invalidOffset.startMs).toBe(Date.UTC(2026, 1, 1));
205239
expect(invalidOffset.endMs).toBe(Date.UTC(2026, 1, 2) + dayMs - 1);
206240
});
207241

208-
it("parseDateRange uses specific offset for today/day math after UTC midnight", () => {
242+
it("resolveDateRange uses specific offset for today/day math after UTC midnight", () => {
209243
vi.useFakeTimers();
210244
vi.setSystemTime(new Date("2026-02-17T03:57:00.000Z"));
211-
const range = testApi.parseDateRange({
212-
days: 1,
213-
mode: "specific",
214-
utcOffset: "UTC-5",
215-
});
245+
const range = expectDateRange(
246+
testApi.resolveDateRange({
247+
days: 1,
248+
mode: "specific",
249+
utcOffset: "UTC-5",
250+
}),
251+
);
216252
expect(range.startMs).toBe(Date.UTC(2026, 1, 16, 5, 0, 0, 0));
217253
expect(range.endMs).toBe(Date.UTC(2026, 1, 17, 4, 59, 59, 999));
218254
});
219255

220-
it("parseDateRange uses gateway local day boundaries in gateway mode", () => {
256+
it("resolveDateRange uses gateway local day boundaries in gateway mode", () => {
221257
vi.useFakeTimers();
222258
vi.setSystemTime(new Date("2026-02-05T12:34:56.000Z"));
223-
const range = testApi.parseDateRange({ days: 1, mode: "gateway" });
259+
const range = expectDateRange(testApi.resolveDateRange({ days: 1, mode: "gateway" }));
224260
const expectedStart = new Date(2026, 1, 5).getTime();
225261
expect(range.startMs).toBe(expectedStart);
226262
expect(range.endMs).toBe(expectedStart + dayMs - 1);
227263
});
228264

229-
it("parseDateRange clamps days to at least 1 and defaults to 30 days", () => {
265+
it("resolveDateRange clamps days to at least 1 and defaults to 30 days", () => {
230266
vi.useFakeTimers();
231267
vi.setSystemTime(new Date("2026-02-05T12:34:56.000Z"));
232-
const oneDay = testApi.parseDateRange({ days: 0 });
268+
const oneDay = expectDateRange(testApi.resolveDateRange({ days: 0 }));
233269
expect(oneDay.endMs).toBe(Date.UTC(2026, 1, 5) + dayMs - 1);
234270
expect(oneDay.startMs).toBe(Date.UTC(2026, 1, 5));
235271

236-
const def = testApi.parseDateRange({});
272+
const def = expectDateRange(testApi.resolveDateRange({}));
237273
expect(def.endMs).toBe(Date.UTC(2026, 1, 5) + dayMs - 1);
238274
expect(def.startMs).toBe(Date.UTC(2026, 1, 5) - 29 * dayMs);
239275
});

src/gateway/server-methods/usage.ts

Lines changed: 37 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ const SESSIONS_USAGE_CACHE_READ_CONCURRENCY = 12;
6969
const DAY_MS = 24 * 60 * 60 * 1000;
7070

7171
type DateRange = { startMs: number; endMs: number };
72+
// Keep validation and parsed timestamps in one result so handlers cannot forward
73+
// an invalid or backwards window to the usage loaders.
74+
type DateRangeResolution = { ok: true; value: DateRange } | { ok: false; error: string };
7275
type DateInterpretation =
7376
| { mode: "utc" | "gateway" }
7477
| { mode: "specific"; utcOffsetMinutes: number };
@@ -172,7 +175,7 @@ const parseDateParts = (
172175
// undefined for both absent and invalid input, so an explicitly supplied but unparseable
173176
// date (bad format or impossible calendar date like 2026-02-30) would otherwise silently
174177
// fall through to the default range and return a successful response for an unrelated range.
175-
// Return the offending field so handlers can reject it instead of querying the wrong window.
178+
// Return the offending field so range resolution can reject it instead of querying the wrong window.
176179
const findInvalidExplicitDate = (params: {
177180
startDate?: unknown;
178181
endDate?: unknown;
@@ -309,14 +312,22 @@ const resolveRangeDays = (raw: unknown): number | "all" | undefined => {
309312
* Get date range from params (startDate/endDate or days).
310313
* Falls back to last 30 days if not provided.
311314
*/
312-
const parseDateRange = (params: {
315+
const resolveDateRange = (params: {
313316
startDate?: unknown;
314317
endDate?: unknown;
315318
days?: unknown;
316319
range?: unknown;
317320
mode?: unknown;
318321
utcOffset?: unknown;
319-
}): DateRange => {
322+
}): DateRangeResolution => {
323+
const invalidDate = findInvalidExplicitDate(params);
324+
if (invalidDate) {
325+
return {
326+
ok: false,
327+
error: `invalid ${invalidDate}: expected a valid YYYY-MM-DD calendar date`,
328+
};
329+
}
330+
320331
const now = new Date();
321332
const interpretation = resolveDateInterpretation(params);
322333
const todayStartMs = getTodayStartMs(now, interpretation);
@@ -326,29 +337,32 @@ const parseDateRange = (params: {
326337
const endMs = parseDateToMs(params.endDate, interpretation);
327338

328339
if (startMs !== undefined && endMs !== undefined) {
340+
if (startMs > endMs) {
341+
return { ok: false, error: "startDate must not be after endDate" };
342+
}
329343
// endMs should be end of day
330-
return { startMs, endMs: endMs + DAY_MS - 1 };
344+
return { ok: true, value: { startMs, endMs: endMs + DAY_MS - 1 } };
331345
}
332346

333347
const rangeDays = resolveRangeDays(params.range);
334348
if (rangeDays === "all") {
335-
return { startMs: 0, endMs: todayEndMs };
349+
return { ok: true, value: { startMs: 0, endMs: todayEndMs } };
336350
}
337351
if (rangeDays !== undefined) {
338352
const start = todayStartMs - (rangeDays - 1) * DAY_MS;
339-
return { startMs: start, endMs: todayEndMs };
353+
return { ok: true, value: { startMs: start, endMs: todayEndMs } };
340354
}
341355

342356
const days = parseDays(params.days);
343357
if (days !== undefined) {
344358
const clampedDays = Math.max(1, days);
345359
const start = todayStartMs - (clampedDays - 1) * DAY_MS;
346-
return { startMs: start, endMs: todayEndMs };
360+
return { ok: true, value: { startMs: start, endMs: todayEndMs } };
347361
}
348362

349363
// Default to last 30 days
350364
const defaultStartMs = todayStartMs - 29 * DAY_MS;
351-
return { startMs: defaultStartMs, endMs: todayEndMs };
365+
return { ok: true, value: { startMs: defaultStartMs, endMs: todayEndMs } };
352366
};
353367

354368
type DiscoveredSessionWithAgent = DiscoveredSession & { agentId: string };
@@ -875,13 +889,12 @@ function mergeUsageCacheStatus(
875889
// Exposed for unit tests (kept as a single export to avoid widening the public API surface).
876890
export const testApi = {
877891
parseDateParts,
878-
findInvalidExplicitDate,
879892
parseUtcOffsetToMinutes,
880893
resolveDateInterpretation,
881894
parseDateToMs,
882895
getTodayStartMs,
883896
parseDays,
884-
parseDateRange,
897+
resolveDateRange,
885898
discoverAllSessionsForUsage,
886899
loadCostUsageSummaryCached,
887900
costUsageCache,
@@ -896,30 +909,20 @@ export const usageHandlers: GatewayRequestHandlers = {
896909
respond(true, summary, undefined);
897910
},
898911
"usage.cost": async ({ respond, params, context }) => {
899-
const invalidDate = findInvalidExplicitDate({
900-
startDate: params?.startDate,
901-
endDate: params?.endDate,
902-
});
903-
if (invalidDate) {
904-
respond(
905-
false,
906-
undefined,
907-
errorShape(
908-
ErrorCodes.INVALID_REQUEST,
909-
`invalid ${invalidDate}: expected a valid YYYY-MM-DD calendar date`,
910-
),
911-
);
912-
return;
913-
}
914-
const config = context.getRuntimeConfig();
915-
const { startMs, endMs } = parseDateRange({
912+
const dateRange = resolveDateRange({
916913
startDate: params?.startDate,
917914
endDate: params?.endDate,
918915
days: params?.days,
919916
range: params?.range,
920917
mode: params?.mode,
921918
utcOffset: params?.utcOffset,
922919
});
920+
if (!dateRange.ok) {
921+
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, dateRange.error));
922+
return;
923+
}
924+
const config = context.getRuntimeConfig();
925+
const { startMs, endMs } = dateRange.value;
923926
const agentId = normalizeOptionalString(params?.agentId);
924927
const agentScope = params?.agentScope === "all" && !agentId ? "all" : undefined;
925928
const summary = await loadCostUsageSummaryCached({
@@ -945,26 +948,19 @@ export const usageHandlers: GatewayRequestHandlers = {
945948
}
946949

947950
const p = params;
948-
const invalidDate = findInvalidExplicitDate({ startDate: p.startDate, endDate: p.endDate });
949-
if (invalidDate) {
950-
respond(
951-
false,
952-
undefined,
953-
errorShape(
954-
ErrorCodes.INVALID_REQUEST,
955-
`invalid ${invalidDate}: expected a valid YYYY-MM-DD calendar date`,
956-
),
957-
);
958-
return;
959-
}
960-
const config = context.getRuntimeConfig();
961-
const { startMs, endMs } = parseDateRange({
951+
const dateRange = resolveDateRange({
962952
startDate: p.startDate,
963953
endDate: p.endDate,
964954
range: p.range,
965955
mode: p.mode,
966956
utcOffset: p.utcOffset,
967957
});
958+
if (!dateRange.ok) {
959+
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, dateRange.error));
960+
return;
961+
}
962+
const config = context.getRuntimeConfig();
963+
const { startMs, endMs } = dateRange.value;
968964
const limit = typeof p.limit === "number" && Number.isFinite(p.limit) ? p.limit : 50;
969965
const includeContextWeight = p.includeContextWeight ?? false;
970966
const specificKey = normalizeOptionalString(p.key) ?? null;

0 commit comments

Comments
 (0)