Skip to content

Commit a32517a

Browse files
authored
test(validation): add invalid timezone boundary checks for tz parameter (JhaSourav07#1999)
## Description Fixes JhaSourav07#1433 Added validation coverage for invalid timezone inputs passed through the `tz` query parameter. ### Changes Made * Added a test case using the invalid timezone value `Mars/Cyonia` * Added schema-level timezone validation * Verified invalid IANA timezone identifiers are rejected * Ensured the expected `Invalid timezone` validation error is returned ## Pillar * [ ] 🎨 Pillar 1 — New Theme Design * [ ] 📐 Pillar 2 — Geometric SVG Improvement * [x] 🕐 Pillar 3 — Timezone Logic Optimization * [ ] 🛠️ Other (Bug fix, refactoring, docs) ## Visual Preview N/A — Validation and test coverage changes only. ## Checklist before requesting a review: * [x] I have read the `CONTRIBUTING.md` file. * [x] I have tested these changes locally * [x] I have run `npm run format` and `npm run lint` locally and resolved all errors (CI will fail otherwise). * [x] My commits follow the Conventional Commits format (e.g., `feat(themes): ...`, `fix(calculate): ...`). * [ ] I have updated `README.md` if I added a new theme or URL parameter. * [x] I have started the repo. * [x] I have made sure that i have only one commit to merge in this PR. * [x] The SVG output matches the CommitPulse "premium quality" aesthetic standard (no raw elements, smooth animations, correct fonts). * [x] (Recommended) I joined the CommitPulse Discord community for contributor discussions, mentorship, and faster PR support.
2 parents ff9c152 + 093df22 commit a32517a

4 files changed

Lines changed: 49 additions & 22 deletions

File tree

app/api/stats/route.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,22 @@ export async function GET(request: Request) {
2424
const parseResult = statsParamsSchema.safeParse(Object.fromEntries(searchParams.entries()));
2525

2626
if (!parseResult.success) {
27+
const details = parseResult.error.flatten();
28+
29+
if (details.fieldErrors.tz?.length) {
30+
return NextResponse.json(
31+
{
32+
error: 'Invalid "tz" parameter',
33+
details,
34+
},
35+
{ status: 400 }
36+
);
37+
}
38+
2739
return NextResponse.json(
2840
{
2941
error: 'Invalid parameters',
30-
details: parseResult.error.flatten(),
42+
details,
3143
},
3244
{ status: 400 }
3345
);

app/api/streak/route.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ export async function GET(request: Request) {
9999
glow,
100100
format,
101101
} = parseResult.data;
102-
102+
const normalizedView = view as 'default' | 'monthly' | 'heatmap' | 'pulse';
103103
const themeName = theme || 'dark';
104104
const from = customFrom
105105
? new Date(customFrom).toISOString()
@@ -161,7 +161,7 @@ export async function GET(request: Request) {
161161
hideBackground: hide_background,
162162
hide_stats,
163163
lang,
164-
view,
164+
view: normalizedView,
165165
delta_format,
166166
width,
167167
height,
@@ -278,17 +278,17 @@ export async function GET(request: Request) {
278278

279279
// ─── SVG output mode (default) ──────────────────────────────────────────
280280
let svg = '';
281-
if (view === 'monthly') {
281+
if (normalizedView === 'monthly') {
282282
const stats = calculateMonthlyStats(
283283
calendar,
284284
timezone,
285285
getMonthlyReferenceDate(year, timezone)
286286
);
287287
svg = generateMonthlySVG(stats, params);
288-
} else if (view === 'heatmap') {
288+
} else if (normalizedView === 'heatmap') {
289289
const stats = calculateStreak(calendar, timezone, undefined, grace);
290290
svg = generateHeatmapSVG(stats, params, calendar);
291-
} else if (view === 'pulse') {
291+
} else if (normalizedView === 'pulse') {
292292
// We still use calculateStreak here to efficiently parse totalContributions for the stat display,
293293
// even though the sparkline generator will extract its own daily 30-day timeline below.
294294
const stats = calculateStreak(calendar, timezone, undefined, grace);

lib/validations.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,19 @@ describe('streakParamsSchema', () => {
450450
expect(result.data.delta_format).toBe('percent');
451451
}
452452
});
453+
454+
it('rejects invalid IANA timezone names', () => {
455+
const result = streakParamsSchema.safeParse({
456+
user: 'octocat',
457+
tz: 'Mars/Cyonia',
458+
});
459+
460+
expect(result.success).toBe(false);
461+
462+
if (!result.success) {
463+
expect(result.error.issues[0]?.message).toBe('Invalid timezone');
464+
}
465+
});
453466
});
454467

455468
it('should succeed when username contains hyphens', () => {

lib/validations.ts

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,22 @@ function dimensionParam(name: string, min: number, max: number) {
7474
.transform(toDimensionValue);
7575
}
7676

77+
function isValidTimeZone(tz?: string): boolean {
78+
if (!tz) return true;
79+
80+
try {
81+
Intl.DateTimeFormat(undefined, { timeZone: tz });
82+
return true;
83+
} catch {
84+
return false;
85+
}
86+
}
87+
88+
const timeZoneParam = z
89+
.string()
90+
.optional()
91+
.refine(isValidTimeZone, { message: 'Invalid timezone' });
92+
7793
export const GITHUB_USERNAME_REGEX = /^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9]))*$/;
7894

7995
const baseStreakParamsSchema = z.object({
@@ -244,26 +260,12 @@ const baseStreakParamsSchema = z.object({
244260
},
245261
{ message: 'Invalid "date" format. Use ISO 8601.' }
246262
),
247-
tz: z
248-
.string()
249-
.optional()
250-
.refine(
251-
(val) => {
252-
if (!val) return true;
253-
try {
254-
new Intl.DateTimeFormat(undefined, { timeZone: val });
255-
return true;
256-
} catch {
257-
return false;
258-
}
259-
},
260-
{ message: 'Invalid timezone. Must be a valid IANA timezone (e.g. America/New_York).' }
261-
),
262263
refresh: z.string().optional().transform(toRefreshFlag),
263264
hide_title: z.string().optional().transform(toBooleanFlag),
264265
hide_background: z.string().optional().transform(toBooleanFlag),
265266
hide_stats: z.string().optional().transform(toBooleanFlag),
266267
lang: z.enum(supportedLanguages).catch('en').default('en'),
268+
tz: timeZoneParam,
267269
// Unknown view values fall back to the default dashboard view.
268270
view: z.enum(['default', 'monthly', 'heatmap', 'pulse']).catch('default').default('default'),
269271
// Invalid delta formats fall back to percentage mode.
@@ -435,7 +437,7 @@ export const statsParamsSchema = z.object({
435437
message: 'Invalid GitHub username',
436438
}),
437439
refresh: z.string().optional().transform(toRefreshFlag),
438-
tz: z.string().optional(),
440+
tz: timeZoneParam,
439441
});
440442

441443
export const wrappedParamsSchema = z.object({

0 commit comments

Comments
 (0)