@@ -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&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