Skip to content

Commit e1f0c22

Browse files
authored
test(api validation): check query validation boundaries for ?layout= … (JhaSourav07#2013)
test(api validation): check query validation boundaries for ?layout= parameter (Variation 2)
1 parent 1a2435f commit e1f0c22

3 files changed

Lines changed: 170 additions & 81 deletions

File tree

app/api/streak/route.test.ts

Lines changed: 34 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -103,20 +103,42 @@ describe('GET /api/streak', () => {
103103
});
104104

105105
describe('parameter validation', () => {
106-
it('falls back to default layout when an unsupported layout is provided', async () => {
106+
it('returns 400 Bad Request when ?layout= is set to an unsupported format', async () => {
107107
const response = await GET(
108108
makeRequest({
109109
user: 'octocat',
110110
layout: 'unsupported_layout',
111111
})
112112
);
113113

114-
expect(response.status).toBe(200);
114+
expect(response.status).toBe(400);
115+
});
115116

116-
const body = await response.text();
117+
it('does not call the GitHub API when layout is invalid', async () => {
118+
await GET(
119+
makeRequest({
120+
user: 'octocat',
121+
layout: 'unsupported_layout',
122+
})
123+
);
117124

118-
expect(body).toContain('<svg');
125+
expect(fetchGitHubContributions).not.toHaveBeenCalled();
126+
});
127+
128+
it('returns 400 with a structured error body for unsupported_layout', async () => {
129+
const response = await GET(
130+
makeRequest({
131+
user: 'octocat',
132+
layout: 'unsupported_layout',
133+
})
134+
);
135+
136+
expect(response.status).toBe(400);
137+
const body = await response.json();
138+
expect(body.error).toBe('Invalid parameters');
139+
expect(body.details).not.toBeNull();
119140
});
141+
120142
it('returns 400 when the user parameter is missing', async () => {
121143
const response = await GET(makeRequest());
122144

@@ -155,6 +177,7 @@ describe('GET /api/streak', () => {
155177

156178
expect(fetchGitHubContributions).not.toHaveBeenCalled();
157179
});
180+
158181
it('returns 400 when user contains spaces', async () => {
159182
const response = await GET(makeRequest({ user: 'john doe' }));
160183
const body = await response.json();
@@ -189,12 +212,12 @@ describe('GET /api/streak', () => {
189212
expect(fetchGitHubContributions).not.toHaveBeenCalled();
190213
});
191214

192-
it('returns 200 for unsupported ?layout query parameter values (route ignores it)', async () => {
215+
it('returns 400 for unsupported ?layout query parameter values (strict schema validation)', async () => {
193216
const response = await GET(
194217
new Request('http://localhost:3000/api/streak?user=octocat&layout=unsupported_layout')
195218
);
196219

197-
expect(response.status).toBe(200);
220+
expect(response.status).toBe(400);
198221
});
199222

200223
it('should return 200 OK and valid SVG when the optional repo query parameter is provided', async () => {
@@ -501,8 +524,6 @@ describe('GET /api/streak', () => {
501524
});
502525

503526
it('defaults to linear scale when an unknown scale value is given', async () => {
504-
// The route only accepts "log" — anything else is treated as "linear".
505-
// A 200 response confirms no crash; the generator silently uses the default.
506527
const response = await GET(makeRequest({ user: 'octocat', scale: 'exponential' }));
507528

508529
expect(response.status).toBe(200);
@@ -609,14 +630,12 @@ describe('GET /api/streak', () => {
609630
});
610631

611632
it('accepts year=2008 (the earliest valid year)', async () => {
612-
// 2008 is the GitHub founding year — the lower boundary of the valid range
613633
const response = await GET(makeRequest({ user: 'octocat', year: '2008' }));
614634

615635
expect(response.status).toBe(200);
616636
});
617637

618638
it('accepts the current year', async () => {
619-
// Upper boundary — the current year must always be accepted
620639
const currentYear = new Date().getFullYear().toString();
621640
const response = await GET(makeRequest({ user: 'octocat', year: currentYear }));
622641

@@ -657,7 +676,6 @@ describe('GET /api/streak', () => {
657676
});
658677

659678
it('clamps negative radius to 0', async () => {
660-
// sanitizeRadius uses Math.max(0, ...) so negatives must floor at 0
661679
const response = await GET(makeRequest({ user: 'octocat', radius: '-5' }));
662680
const body = await response.text();
663681

@@ -666,7 +684,6 @@ describe('GET /api/streak', () => {
666684
});
667685

668686
it('handles non-numeric radius gracefully', async () => {
669-
// sanitizeRadius returns the fallback (8) when parseInt produces NaN
670687
const response = await GET(makeRequest({ user: 'octocat', radius: 'abc' }));
671688

672689
expect(response.status).toBe(200);
@@ -712,7 +729,7 @@ describe('GET /api/streak', () => {
712729
const body = await response.text();
713730

714731
expect(response.status).toBe(200);
715-
expect(body).toContain('58a6ff'); // Dark theme accent is #58a6ff — confirms the dark fallback is actually applied
732+
expect(body).toContain('58a6ff');
716733
});
717734
});
718735

@@ -745,13 +762,7 @@ describe('GET /api/streak', () => {
745762
});
746763

747764
it('returns 400 when an invalid hex color is passed as accent', async () => {
748-
// #ZZZZZZ contains non-hex characters — schema must reject it with 400
749-
const response = await GET(makeRequest({ user: 'octocat', accent: '#ZZZZZZ' }));
750-
751-
expect(response.status).toBe(400);
752-
});
753-
it('returns 400 when another invalid hex color is passed as accent (Variation 4)', async () => {
754-
const response = await GET(makeRequest({ user: 'octocat', accent: '#ZZZZZZ' }));
765+
const response = await GET(makeRequest({ user: 'octocat', accent: '#ZZZZZZZ' }));
755766

756767
expect(response.status).toBe(400);
757768
});
@@ -838,8 +849,6 @@ describe('GET /api/streak', () => {
838849
});
839850

840851
it('returns a valid 500 SVG even when something non-Error is thrown', async () => {
841-
// JavaScript lets you throw anything — strings, numbers, plain objects.
842-
// The catch block checks instanceof Error; if that fails it falls back to "Unknown error".
843852
vi.mocked(fetchGitHubContributions).mockRejectedValue('something went very wrong');
844853

845854
const response = await GET(makeRequest({ user: 'octocat' }));
@@ -885,7 +894,6 @@ describe('GET /api/streak', () => {
885894
const body = await response.json();
886895

887896
expect(response.status).toBe(400);
888-
// Zod validates the input and returns a JSON error — no raw user input reflected
889897
expect(body.details.fieldErrors.tz[0]).toContain('Invalid timezone');
890898
});
891899

@@ -896,8 +904,6 @@ describe('GET /api/streak', () => {
896904
});
897905

898906
it('uses getSecondsUntilMidnightInTimezone (not UTC) for the cache TTL when ?tz= is set', async () => {
899-
// getSecondsUntilMidnightInTimezone is mocked to return 7200 in beforeEach.
900-
// getSecondsUntilUTCMidnight returns 3600. The header should use 7200.
901907
const response = await GET(makeRequest({ user: 'octocat', tz: 'America/New_York' }));
902908

903909
expect(response.headers.get('Cache-Control')).toBe(
@@ -926,13 +932,7 @@ describe('GET /api/streak', () => {
926932
expect(getSecondsUntilMidnightInTimezone).toHaveBeenCalledWith('Australia/Sydney');
927933
});
928934

929-
// =========================================================================
930-
// ISSUE OBJECTIVE: Reject fictitious planetary timezone (Variation 4)
931-
// =========================================================================
932935
it('returns 400 when a fictitious planetary timezone Mars/Cyonia is supplied', async () => {
933-
// Mars/Cyonia is structurally plausible (Region/City format) but does not
934-
// exist in the IANA tz database — the schema must reject it before the
935-
// request reaches the GitHub API.
936936
const response = await GET(makeRequest({ user: 'octocat', tz: 'Mars/Cyonia' }));
937937

938938
expect(response.status).toBe(400);
@@ -1020,23 +1020,18 @@ describe('GET /api/streak', () => {
10201020

10211021
expect(response.status).toBe(200);
10221022
const body = await response.text();
1023-
// It should generate the default streak SVG and have "CURRENT_STREAK"
10241023
expect(body).toContain('CURRENT_STREAK');
10251024
});
10261025

10271026
it('returns streak view when view=streak is given', async () => {
1028-
// "streak" is not in the enum so .catch("default") applies — same output as default
10291027
const response = await GET(makeRequest({ user: 'octocat', view: 'streak' }));
10301028
const body = await response.text();
10311029

10321030
expect(response.status).toBe(200);
10331031
expect(body).toContain('CURRENT_STREAK');
10341032
});
1035-
// =========================================================================
1036-
// ISSUE OBJECTIVE: Custom dimensions in monthly view (?width & ?height)
1037-
// =========================================================================
1033+
10381034
it('applies custom width and height parameters to the monthly SVG', async () => {
1039-
// 1. Create a fresh mock function and inject it globally just for this test
10401035
const tempMockFetch = vi.fn().mockResolvedValueOnce({
10411036
ok: true,
10421037
json: async () => ({
@@ -1055,30 +1050,20 @@ describe('GET /api/streak', () => {
10551050
});
10561051
vi.stubGlobal('fetch', tempMockFetch);
10571052

1058-
// 2. Make request using the file's built-in makeRequest helper (No 'any' needed!)
10591053
const req = makeRequest({ user: 'octocat', view: 'monthly', width: '400', height: '150' });
10601054
const res = await GET(req);
10611055

1062-
// Assert status is 200 OK
10631056
expect(res.status).toBe(200);
10641057

10651058
const body = await res.text();
10661059

1067-
// 3. Assert body contains width="400"
10681060
expect(body).toContain('width="400"');
1069-
1070-
// 4. Assert body contains height="150"
10711061
expect(body).toContain('height="150"');
10721062

1073-
// Cleanup the stub so it doesn't leak into other tests
10741063
vi.unstubAllGlobals();
10751064
});
10761065

1077-
// =========================================================================
1078-
// ISSUE OBJECTIVE: Route test for ?view=monthly&delta_format=both
1079-
// =========================================================================
10801066
it('applies delta_format=both to show percent and absolute values in the monthly SVG', async () => {
1081-
// 1. Mock the GitHub fetch with actual weekly data using vi.mocked
10821067
vi.mocked(fetchGitHubContributions).mockResolvedValueOnce({
10831068
calendar: {
10841069
totalContributions: 150,
@@ -1090,30 +1075,22 @@ describe('GET /api/streak', () => {
10901075
repoContributions: [],
10911076
} as unknown as ExtendedContributionData);
10921077

1093-
// 2. Lock the system time to May 2026 so the calendar calculation aligns
10941078
vi.useFakeTimers();
10951079
vi.setSystemTime(new Date('2026-05-20T12:00:00Z'));
10961080

1097-
// 3. Make request using the file's built-in makeRequest helper
10981081
const req = makeRequest({ user: 'octocat', view: 'monthly', delta_format: 'both' });
10991082
const res = await GET(req);
11001083

11011084
expect(res.status).toBe(200);
11021085

11031086
const body = await res.text();
11041087

1105-
// 4. Assert body contains % (percent part)
11061088
expect(body).toContain('%');
11071089

1108-
// Cleanup
11091090
vi.useRealTimers();
11101091
});
11111092

1112-
// =========================================================================
1113-
// ISSUE OBJECTIVE: Route test for ?view=monthly&delta_format=absolute
1114-
// =========================================================================
11151093
it('applies delta_format=absolute to show raw commit counts in the monthly SVG', async () => {
1116-
// 1. Mock the GitHub fetch with actual weekly data using vi.mocked
11171094
vi.mocked(fetchGitHubContributions).mockResolvedValueOnce({
11181095
calendar: {
11191096
totalContributions: 150,
@@ -1124,22 +1101,19 @@ describe('GET /api/streak', () => {
11241101
} as ContributionCalendar,
11251102
repoContributions: [],
11261103
} as unknown as ExtendedContributionData);
1127-
// 2. Lock the system time to May 2026 so the calendar calculation aligns
1104+
11281105
vi.useFakeTimers();
11291106
vi.setSystemTime(new Date('2026-05-20T12:00:00Z'));
11301107

1131-
// 3. Make request using the file's built-in makeRequest helper
11321108
const req = makeRequest({ user: 'octocat', view: 'monthly', delta_format: 'absolute' });
11331109
const res = await GET(req);
11341110

11351111
expect(res.status).toBe(200);
11361112

11371113
const body = await res.text();
11381114

1139-
// 4. Assert body contains 'commits' (the absolute delta unit)
11401115
expect(body).toContain('commits');
11411116

1142-
// Cleanup
11431117
vi.useRealTimers();
11441118
});
11451119
});
@@ -1174,6 +1148,7 @@ describe('GET /api/streak', () => {
11741148
expect(body).toContain('stroke-opacity="0.3"');
11751149
});
11761150
});
1151+
11771152
describe('lang parameter', () => {
11781153
it('returns Spanish translations when ?lang=es is given', async () => {
11791154
const response = await GET(makeRequest({ user: 'octocat', lang: 'es' }));
@@ -1214,7 +1189,6 @@ describe('GET /api/streak', () => {
12141189
const body = await response.text();
12151190

12161191
expect(response.status).toBe(200);
1217-
// Default body font is Space Grotesk
12181192
expect(body).toContain('Space Grotesk');
12191193
});
12201194

@@ -1232,7 +1206,6 @@ describe('GET /api/streak', () => {
12321206

12331207
expect(response.status).toBe(200);
12341208
expect(body).toContain('Space Grotesk');
1235-
// Whitespace-only should not produce a Google Fonts import with an empty family
12361209
expect(body).not.toContain('family=+&display=swap');
12371210
});
12381211

@@ -1267,7 +1240,6 @@ describe('GET /api/streak', () => {
12671240

12681241
expect(response.status).toBe(200);
12691242
expect(body).toContain('Space Grotesk');
1270-
// No empty Google Fonts import should be emitted
12711243
expect(body).not.toContain('family=&display=swap');
12721244
});
12731245

@@ -1276,15 +1248,12 @@ describe('GET /api/streak', () => {
12761248
const body = await response.text();
12771249

12781250
expect(response.status).toBe(200);
1279-
// The quote is stripped; the cleaned name "Inter" should still be used
12801251
expect(body).toContain('Inter');
1281-
// The raw unescaped quote must not appear in the SVG output
12821252
expect(body).not.toContain("font: 'Inter\"'");
12831253
expect(body).not.toContain('font-family: Inter"');
12841254
});
12851255

12861256
it('rejects a font name containing a semicolon (CSS injection attempt)', async () => {
1287-
// sanitizeGoogleFontUrl rejects names with semicolons — no Google Fonts import
12881257
const response = await GET(makeRequest({ user: 'octocat', font: 'Inter; @import evil' }));
12891258
const body = await response.text();
12901259

@@ -1294,15 +1263,11 @@ describe('GET /api/streak', () => {
12941263
});
12951264

12961265
it('strips special characters from a URL-like font name (path traversal / injection attempt)', async () => {
1297-
// sanitizeFont strips ":", "/", "." — "https://evil.com" becomes "httpsevilcom"
1298-
// sanitizeGoogleFontUrl then rejects it because "." is not in the whitelist
12991266
const response = await GET(makeRequest({ user: 'octocat', font: 'https://evil.com' }));
13001267
const body = await response.text();
13011268

13021269
expect(response.status).toBe(200);
1303-
// The original URL must not appear verbatim in the SVG
13041270
expect(body).not.toContain('https://evil.com');
1305-
// The domain must not appear in the output
13061271
expect(body).not.toContain('evil.com');
13071272
});
13081273

@@ -1318,14 +1283,11 @@ describe('GET /api/streak', () => {
13181283
});
13191284

13201285
it('does not emit a Google Fonts import when a predefined font is used', async () => {
1321-
// Predefined fonts (fira, jetbrains, roboto) are already bundled via the
1322-
// static @import at the top of the <style> block — no extra import needed.
13231286
const response = await GET(makeRequest({ user: 'octocat', font: 'fira' }));
13241287
const body = await response.text();
13251288

13261289
expect(response.status).toBe(200);
13271290
expect(body).toContain('Fira Code');
1328-
// Should NOT emit a second dynamic import for the same font
13291291
expect(body).not.toContain('family=fira&display=swap');
13301292
});
13311293

0 commit comments

Comments
 (0)