Skip to content

Commit 6101aa8

Browse files
plcclaude
andcommitted
Validate event date formats before hitting Postgres
Garbage dates like "not-a-date" previously passed through to Postgres, which rejected them with a 500. Now caught at the app layer with a clean 400 for both POST and PATCH on non-all_day events. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 99a5fe1 commit 6101aa8

File tree

2 files changed

+54
-0
lines changed

2 files changed

+54
-0
lines changed

src/routes/events.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,12 @@ function normalizeBody(body) {
234234

235235
const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
236236

237+
function isValidDatetime(str) {
238+
if (typeof str !== 'string') return false;
239+
const d = new Date(str);
240+
return !isNaN(d.getTime());
241+
}
242+
237243
/**
238244
* Normalize start/end for all-day events.
239245
* Input: inclusive dates like "2025-03-15" (start) and "2025-03-15" (end = same day).
@@ -299,6 +305,13 @@ router.post('/:id/events', async (req, res) => {
299305
return res.status(400).json({ error: 'start date must not be after end date' });
300306
}
301307
({ startTime, endTime } = normalizeAllDay(start, end));
308+
} else {
309+
if (!isValidDatetime(start)) {
310+
return res.status(400).json({ error: 'start must be a valid ISO 8601 datetime' });
311+
}
312+
if (!isValidDatetime(end)) {
313+
return res.status(400).json({ error: 'end must be a valid ISO 8601 datetime' });
314+
}
302315
}
303316

304317
const id = eventId();
@@ -560,6 +573,13 @@ router.patch('/:id/events/:event_id', async (req, res) => {
560573
if (DATE_ONLY_RE.test(s) && DATE_ONLY_RE.test(e)) {
561574
({ startTime, endTime } = normalizeAllDay(s, e));
562575
}
576+
} else {
577+
if (start !== undefined && !isValidDatetime(start)) {
578+
return res.status(400).json({ error: 'start must be a valid ISO 8601 datetime' });
579+
}
580+
if (end !== undefined && !isValidDatetime(end)) {
581+
return res.status(400).json({ error: 'end must be a valid ISO 8601 datetime' });
582+
}
563583
}
564584

565585
// ---- Case A: Patching an instance (has parent_event_id) ----

tests/api.test.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1174,6 +1174,40 @@ describe('Input validation', { concurrency: 1 }, () => {
11741174
await api('DELETE', `/calendars/${valCalId}/events/${created.id}`, { token: state.apiKey });
11751175
});
11761176

1177+
it('rejects invalid start datetime on POST', async () => {
1178+
const { status, data } = await api('POST', `/calendars/${valCalId}/events`, {
1179+
token: state.apiKey,
1180+
body: { title: 'Bad date', start: 'not-a-date', end: futureDate(2) },
1181+
});
1182+
assert.equal(status, 400);
1183+
assert.ok(data.error.includes('start'));
1184+
});
1185+
1186+
it('rejects invalid end datetime on POST', async () => {
1187+
const { status, data } = await api('POST', `/calendars/${valCalId}/events`, {
1188+
token: state.apiKey,
1189+
body: { title: 'Bad date', start: futureDate(1), end: 'garbage' },
1190+
});
1191+
assert.equal(status, 400);
1192+
assert.ok(data.error.includes('end'));
1193+
});
1194+
1195+
it('rejects invalid start datetime on PATCH', async () => {
1196+
const { data: created } = await api('POST', `/calendars/${valCalId}/events`, {
1197+
token: state.apiKey,
1198+
body: { title: 'Temp', start: futureDate(1), end: futureDate(2) },
1199+
});
1200+
1201+
const { status, data } = await api('PATCH', `/calendars/${valCalId}/events/${created.id}`, {
1202+
token: state.apiKey,
1203+
body: { start: 'not-a-date' },
1204+
});
1205+
assert.equal(status, 400);
1206+
assert.ok(data.error.includes('start'));
1207+
1208+
await api('DELETE', `/calendars/${valCalId}/events/${created.id}`, { token: state.apiKey });
1209+
});
1210+
11771211
it('rejects FREQ=MINUTELY', async () => {
11781212
const { status, data } = await api('POST', `/calendars/${valCalId}/events`, {
11791213
token: state.apiKey,

0 commit comments

Comments
 (0)