Skip to content

Commit d37d7de

Browse files
authored
test(api): add integration tests for font parameter sanitization (JhaSourav07#801)
## Description Fixes JhaSourav07#780 ## Pillar - [ ] 🎨 Pillar 1 β€” New Theme Design - [ ] πŸ“ Pillar 2 β€” Geometric SVG Improvement - [ ] πŸ• Pillar 3 β€” Timezone Logic Optimization - [x] πŸ› οΈ Other (Bug fix, refactoring, docs) ## Visual Preview N/A β€” test-only PR, no SVG output changes ## Checklist before requesting a review: - [x] I have read the `CONTRIBUTING.md` file. - [x] I have tested these changes locally (`localhost:3000/api/streak?user=YOUR_USERNAME`). - [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 a3dcc4d + dce8de8 commit d37d7de

1 file changed

Lines changed: 171 additions & 0 deletions

File tree

β€Žapp/api/streak/route.test.tsβ€Ž

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,21 @@ describe('GET /api/streak', () => {
310310
expect(response.status).toBe(400);
311311
expect(body.details.fieldErrors.year[0]).toContain('GitHub was founded in 2008');
312312
});
313+
314+
it('accepts year=2008 (the earliest valid year)', async () => {
315+
// 2008 is the GitHub founding year β€” the lower boundary of the valid range
316+
const response = await GET(makeRequest({ user: 'octocat', year: '2008' }));
317+
318+
expect(response.status).toBe(200);
319+
});
320+
321+
it('accepts the current year', async () => {
322+
// Upper boundary β€” the current year must always be accepted
323+
const currentYear = new Date().getFullYear().toString();
324+
const response = await GET(makeRequest({ user: 'octocat', year: currentYear }));
325+
326+
expect(response.status).toBe(200);
327+
});
313328
});
314329

315330
describe('radius parameter', () => {
@@ -333,6 +348,22 @@ describe('GET /api/streak', () => {
333348

334349
expect(body).toContain('rx="50"');
335350
});
351+
352+
it('clamps negative radius to 0', async () => {
353+
// sanitizeRadius uses Math.max(0, ...) so negatives must floor at 0
354+
const response = await GET(makeRequest({ user: 'octocat', radius: '-5' }));
355+
const body = await response.text();
356+
357+
expect(response.status).toBe(200);
358+
expect(body).toContain('rx="0"');
359+
});
360+
361+
it('handles non-numeric radius gracefully', async () => {
362+
// sanitizeRadius returns the fallback (8) when parseInt produces NaN
363+
const response = await GET(makeRequest({ user: 'octocat', radius: 'abc' }));
364+
365+
expect(response.status).toBe(200);
366+
});
336367
});
337368

338369
describe('theme parameter', () => {
@@ -544,6 +575,15 @@ describe('GET /api/streak', () => {
544575
// It should generate the default streak SVG and have "CURRENT_STREAK"
545576
expect(body).toContain('CURRENT_STREAK');
546577
});
578+
579+
it('returns streak view when view=streak is given', async () => {
580+
// "streak" is not in the enum so .catch("default") applies β€” same output as default
581+
const response = await GET(makeRequest({ user: 'octocat', view: 'streak' }));
582+
const body = await response.text();
583+
584+
expect(response.status).toBe(200);
585+
expect(body).toContain('CURRENT_STREAK');
586+
});
547587
});
548588

549589
describe('theme=random cache header', () => {
@@ -607,4 +647,135 @@ describe('GET /api/streak', () => {
607647
expect(body).toContain('PEAK_STREAK');
608648
});
609649
});
650+
651+
describe('font parameter sanitization', () => {
652+
it('uses the default font when font param is omitted', async () => {
653+
const response = await GET(makeRequest({ user: 'octocat' }));
654+
const body = await response.text();
655+
656+
expect(response.status).toBe(200);
657+
// Default body font is Space Grotesk
658+
expect(body).toContain('Space Grotesk');
659+
});
660+
661+
it('uses the default font when font param is an empty string', async () => {
662+
const response = await GET(makeRequest({ user: 'octocat', font: '' }));
663+
const body = await response.text();
664+
665+
expect(response.status).toBe(200);
666+
expect(body).toContain('Space Grotesk');
667+
});
668+
669+
it('uses the default font when font param is whitespace only', async () => {
670+
const response = await GET(makeRequest({ user: 'octocat', font: ' ' }));
671+
const body = await response.text();
672+
673+
expect(response.status).toBe(200);
674+
expect(body).toContain('Space Grotesk');
675+
// Whitespace-only should not produce a Google Fonts import with an empty family
676+
expect(body).not.toContain('family=+&display=swap');
677+
});
678+
679+
it('passes a valid predefined font name through to the SVG', async () => {
680+
const response = await GET(makeRequest({ user: 'octocat', font: 'jetbrains' }));
681+
const body = await response.text();
682+
683+
expect(response.status).toBe(200);
684+
expect(body).toContain('JetBrains Mono');
685+
});
686+
687+
it('passes a valid custom font name through and emits a Google Fonts import', async () => {
688+
const response = await GET(makeRequest({ user: 'octocat', font: 'Inter' }));
689+
const body = await response.text();
690+
691+
expect(response.status).toBe(200);
692+
expect(body).toContain('family=Inter');
693+
expect(body).toContain('"Inter", sans-serif');
694+
});
695+
696+
it('encodes spaces in multi-word font names as "+" in the Google Fonts URL', async () => {
697+
const response = await GET(makeRequest({ user: 'octocat', font: 'Open Sans' }));
698+
const body = await response.text();
699+
700+
expect(response.status).toBe(200);
701+
expect(body).toContain('family=Open+Sans');
702+
});
703+
704+
it('falls back to the default font when font contains only special characters', async () => {
705+
const response = await GET(makeRequest({ user: 'octocat', font: '!!!' }));
706+
const body = await response.text();
707+
708+
expect(response.status).toBe(200);
709+
expect(body).toContain('Space Grotesk');
710+
// No empty Google Fonts import should be emitted
711+
expect(body).not.toContain('family=&display=swap');
712+
});
713+
714+
it('strips dangerous characters from a font name containing a double-quote', async () => {
715+
const response = await GET(makeRequest({ user: 'octocat', font: 'Inter"' }));
716+
const body = await response.text();
717+
718+
expect(response.status).toBe(200);
719+
// The quote is stripped; the cleaned name "Inter" should still be used
720+
expect(body).toContain('Inter');
721+
// The raw unescaped quote must not appear in the SVG output
722+
expect(body).not.toContain("font: 'Inter\"'");
723+
expect(body).not.toContain('font-family: Inter"');
724+
});
725+
726+
it('rejects a font name containing a semicolon (CSS injection attempt)', async () => {
727+
// sanitizeGoogleFontUrl rejects names with semicolons β€” no Google Fonts import
728+
const response = await GET(makeRequest({ user: 'octocat', font: 'Inter; @import evil' }));
729+
const body = await response.text();
730+
731+
expect(response.status).toBe(200);
732+
expect(body).not.toContain('@import evil');
733+
expect(body).not.toContain('family=Inter%3B');
734+
});
735+
736+
it('strips special characters from a URL-like font name (path traversal / injection attempt)', async () => {
737+
// sanitizeFont strips ":", "/", "." β€” "https://evil.com" becomes "httpsevilcom"
738+
// sanitizeGoogleFontUrl then rejects it because "." is not in the whitelist
739+
const response = await GET(makeRequest({ user: 'octocat', font: 'https://evil.com' }));
740+
const body = await response.text();
741+
742+
expect(response.status).toBe(200);
743+
// The original URL must not appear verbatim in the SVG
744+
expect(body).not.toContain('https://evil.com');
745+
// The domain must not appear in the output
746+
expect(body).not.toContain('evil.com');
747+
});
748+
749+
it('rejects a font name containing a script tag (XSS attempt)', async () => {
750+
const response = await GET(
751+
makeRequest({ user: 'octocat', font: '<script>alert(1)</script>' })
752+
);
753+
const body = await response.text();
754+
755+
expect(response.status).toBe(200);
756+
expect(body).not.toContain('<script>');
757+
expect(body).not.toContain('alert(1)');
758+
});
759+
760+
it('does not emit a Google Fonts import when a predefined font is used', async () => {
761+
// Predefined fonts (fira, jetbrains, roboto) are already bundled via the
762+
// static @import at the top of the <style> block β€” no extra import needed.
763+
const response = await GET(makeRequest({ user: 'octocat', font: 'fira' }));
764+
const body = await response.text();
765+
766+
expect(response.status).toBe(200);
767+
expect(body).toContain('Fira Code');
768+
// Should NOT emit a second dynamic import for the same font
769+
expect(body).not.toContain('family=fira&amp;display=swap');
770+
});
771+
772+
it('returns 200 and a valid SVG even when an extreme font value is supplied', async () => {
773+
const response = await GET(makeRequest({ user: 'octocat', font: 'a'.repeat(200) }));
774+
const body = await response.text();
775+
776+
expect(response.status).toBe(200);
777+
expect(body).toContain('<svg');
778+
expect(body).toContain('</svg>');
779+
});
780+
});
610781
});

0 commit comments

Comments
Β (0)