Skip to content

Commit b13a24c

Browse files
authored
Merge branch 'main' into feat/opacity-url-param
2 parents 38ee736 + b818618 commit b13a24c

26 files changed

Lines changed: 2064 additions & 435 deletions

app/api/compare/route.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { GET } from './route';
3+
4+
vi.mock('@/lib/github', () => ({
5+
getFullDashboardData: vi.fn(),
6+
}));
7+
8+
import { getFullDashboardData } from '@/lib/github';
9+
10+
const makeRequest = (search: string) => new Request(`http://localhost:3000/api/compare?${search}`);
11+
12+
describe('GET /api/compare', () => {
13+
beforeEach(() => {
14+
vi.clearAllMocks();
15+
vi.mocked(getFullDashboardData).mockResolvedValue({
16+
calendar: { totalContributions: 50, weeks: [] },
17+
} as never);
18+
});
19+
20+
// ── Validation ────────────────────────────────────────────────────────────
21+
22+
it('returns 400 when user1 is missing', async () => {
23+
const res = await GET(makeRequest('user2=octocat'));
24+
expect(res.status).toBe(400);
25+
});
26+
27+
it('returns 400 when user2 is missing', async () => {
28+
const res = await GET(makeRequest('user1=octocat'));
29+
expect(res.status).toBe(400);
30+
});
31+
32+
it('returns 400 when both users are missing', async () => {
33+
const res = await GET(makeRequest(''));
34+
expect(res.status).toBe(400);
35+
});
36+
37+
it('returns 400 for invalid GitHub username format for user1', async () => {
38+
const res = await GET(makeRequest('user1=-invalid&user2=octocat'));
39+
expect(res.status).toBe(400);
40+
const data = await res.json();
41+
expect(data.details.fieldErrors.user1).toBeDefined();
42+
});
43+
44+
it('returns 400 for invalid GitHub username format for user2', async () => {
45+
const res = await GET(makeRequest('user1=octocat&user2=-invalid'));
46+
expect(res.status).toBe(400);
47+
const data = await res.json();
48+
expect(data.details.fieldErrors.user2).toBeDefined();
49+
});
50+
51+
it('returns 400 for username exceeding 39 characters', async () => {
52+
const res = await GET(makeRequest(`user1=${'a'.repeat(40)}&user2=octocat`));
53+
expect(res.status).toBe(400);
54+
});
55+
56+
it('returns 400 when comparing a user with themselves', async () => {
57+
const res = await GET(makeRequest('user1=octocat&user2=octocat'));
58+
expect(res.status).toBe(400);
59+
const data = await res.json();
60+
expect(data.details.fieldErrors.user2).toContain('Cannot compare a user with themselves.');
61+
});
62+
63+
it('returns 400 for self-comparison regardless of case', async () => {
64+
const res = await GET(makeRequest('user1=OctoCat&user2=octocat'));
65+
expect(res.status).toBe(400);
66+
});
67+
68+
// ── Success ──────────────────────────────────────────────────────────────
69+
70+
it('returns 200 with comparison data for valid users', async () => {
71+
const res = await GET(makeRequest('user1=alice&user2=bob'));
72+
expect(res.status).toBe(200);
73+
const data = await res.json();
74+
expect(data.user1).toBeDefined();
75+
expect(data.user2).toBeDefined();
76+
});
77+
78+
// ── Error handling ────────────────────────────────────────────────────────
79+
80+
it('returns 404 when user1 is not found on GitHub', async () => {
81+
vi.mocked(getFullDashboardData).mockRejectedValueOnce(new Error('Not found'));
82+
const res = await GET(makeRequest('user1=ghost123&user2=octocat'));
83+
expect(res.status).toBe(404);
84+
});
85+
86+
it('returns 404 when user2 is not found on GitHub', async () => {
87+
vi.mocked(getFullDashboardData)
88+
.mockResolvedValueOnce({ calendar: { totalContributions: 0, weeks: [] } } as never)
89+
.mockRejectedValueOnce(new Error('Not found'));
90+
const res = await GET(makeRequest('user1=octocat&user2=ghost123'));
91+
expect(res.status).toBe(404);
92+
});
93+
});

app/api/compare/route.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,23 @@
11
import { NextResponse } from 'next/server';
22
import { getFullDashboardData } from '@/lib/github';
3+
import { compareParamsSchema } from '@/lib/validations';
34

45
export const revalidate = 3600;
56

67
export async function GET(request: Request) {
78
const { searchParams } = new URL(request.url);
8-
const user1 = searchParams.get('user1');
9-
const user2 = searchParams.get('user2');
109

11-
if (!user1 || !user2) {
10+
const parseResult = compareParamsSchema.safeParse(Object.fromEntries(searchParams.entries()));
11+
12+
if (!parseResult.success) {
13+
const fieldErrors = parseResult.error.flatten();
1214
return NextResponse.json(
13-
{ error: 'Both user1 and user2 query parameters are required.' },
15+
{ error: 'Invalid parameters', details: fieldErrors },
1416
{ status: 400 }
1517
);
1618
}
1719

18-
if (user1.toLowerCase() === user2.toLowerCase()) {
19-
return NextResponse.json({ error: 'Cannot compare a user with themselves.' }, { status: 400 });
20-
}
20+
const { user1, user2 } = parseResult.data;
2121

2222
try {
2323
const [result1, result2] = await Promise.allSettled([

app/api/notify/route.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,4 +95,43 @@ describe('GET /api/notify', () => {
9595
const res = await GET(makeRequest('GET', undefined, 'user=testuser'));
9696
expect(res.status).toBe(200);
9797
});
98+
99+
it('masks the email address in GET responses to prevent PII exposure', async () => {
100+
vi.mocked(Notification.findOne).mockResolvedValue({
101+
username: 'testuser',
102+
email: 'john.doe@gmail.com',
103+
frequency: 'weekly',
104+
notifyOnCommit: true,
105+
notifyOnStreak: false,
106+
notifyOnMilestone: true,
107+
} as never);
108+
109+
const res = await GET(makeRequest('GET', undefined, 'user=testuser'));
110+
const body = await res.json();
111+
112+
expect(res.status).toBe(200);
113+
// Assert the exact masked output for a known input
114+
expect(body.data.email).toBe('jo***@gm***.com');
115+
// The full email must never be returned
116+
expect(body.data.email).not.toBe('john.doe@gmail.com');
117+
});
118+
119+
it('masks emails without a TLD dot correctly (no trailing dot)', async () => {
120+
vi.mocked(Notification.findOne).mockResolvedValue({
121+
username: 'localuser',
122+
email: 'admin@localhost',
123+
frequency: 'daily',
124+
notifyOnCommit: true,
125+
notifyOnStreak: true,
126+
notifyOnMilestone: true,
127+
} as never);
128+
129+
const res = await GET(makeRequest('GET', undefined, 'user=localuser'));
130+
const body = await res.json();
131+
132+
expect(res.status).toBe(200);
133+
expect(body.data.email).toBe('ad***@lo***');
134+
// Must not have a trailing dot
135+
expect(body.data.email.endsWith('.')).toBe(false);
136+
});
98137
});

app/api/notify/route.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,31 @@ import dbConnect from '@/lib/mongodb';
33
import { Notification } from '@/models/Notification';
44
import { NotificationPayload, NotificationResponse } from '@/types/index';
55

6+
/**
7+
* Masks an email address to prevent PII exposure in unauthenticated responses.
8+
* Example: "john.doe@gmail.com" → "jo***@gm***.com"
9+
*/
10+
function maskEmail(email: string): string {
11+
const [local, domain] = email.split('@');
12+
if (!local || !domain) return '***@***.***';
13+
14+
const maskedLocal = local.slice(0, Math.min(2, local.length)) + '***';
15+
16+
const dotIndex = domain.lastIndexOf('.');
17+
if (dotIndex === -1) {
18+
// Domain without a TLD (e.g., "localhost") — mask without trailing dot
19+
const maskedDomain = domain.slice(0, Math.min(2, domain.length)) + '***';
20+
return `${maskedLocal}@${maskedDomain}`;
21+
}
22+
23+
const domainName = domain.slice(0, dotIndex);
24+
const tld = domain.slice(dotIndex + 1);
25+
26+
const maskedDomain = domainName.slice(0, Math.min(2, domainName.length)) + '***';
27+
28+
return `${maskedLocal}@${maskedDomain}.${tld}`;
29+
}
30+
631
// ─── POST /api/notify ────────────────────────────────────────────────────────
732
// Register or update email notification preferences for a user
833
export async function POST(req: NextRequest): Promise<NextResponse<NotificationResponse>> {
@@ -106,13 +131,15 @@ export async function GET(req: NextRequest): Promise<NextResponse<NotificationRe
106131
);
107132
}
108133

134+
// Mask the email to prevent PII exposure in unauthenticated GET responses.
135+
// The full email is only accepted on POST (write) — never returned on GET (read).
109136
return NextResponse.json(
110137
{
111138
success: true,
112139
message: 'Notification preferences fetched successfully.',
113140
data: {
114141
username: notification.username,
115-
email: notification.email,
142+
email: maskEmail(notification.email),
116143
frequency: notification.frequency,
117144
preferences: {
118145
notifyOnCommit: notification.notifyOnCommit,

app/api/streak/route.test.ts

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -724,12 +724,17 @@ describe('GET /api/streak', () => {
724724
expect(body).toContain('--cp-bg');
725725
});
726726

727-
it('falls back to the dark theme without crashing when an unknown theme is given', async () => {
728-
const response = await GET(makeRequest({ user: 'octocat', theme: 'does-not-exist' }));
729-
const body = await response.text();
727+
it('returns 400 Bad Request listing allowed themes when an invalid theme is provided', async () => {
728+
const response = await GET(makeRequest({ user: 'octocat', theme: 'nonexistent_theme_name' }));
729+
expect(response.status).toBe(400);
730730

731-
expect(response.status).toBe(200);
732-
expect(body).toContain('58a6ff');
731+
const body = await response.json();
732+
expect(body.error).toBe('Invalid parameters');
733+
const fieldError = body.details.fieldErrors.theme[0];
734+
expect(fieldError).toContain('Invalid theme. Supported themes:');
735+
expect(fieldError).toContain('dark');
736+
expect(fieldError).toContain('light');
737+
expect(fieldError).toContain('neon');
733738
});
734739
});
735740

@@ -1337,4 +1342,54 @@ describe('GET /api/streak', () => {
13371342
expect(body).toContain('strictly for organizations');
13381343
});
13391344
});
1345+
1346+
describe('JSON output mode (format=json)', () => {
1347+
it('returns JSON with correct Content-Type when format=json is set', async () => {
1348+
const response = await GET(makeRequest({ user: 'octocat', format: 'json' }));
1349+
expect(response.status).toBe(200);
1350+
expect(response.headers.get('Content-Type')).toContain('application/json');
1351+
});
1352+
1353+
it('returns stats, monthlyStats, and calendar in JSON response', async () => {
1354+
const response = await GET(makeRequest({ user: 'octocat', format: 'json' }));
1355+
const data = await response.json();
1356+
1357+
expect(data.user).toBe('octocat');
1358+
expect(data.stats).toBeDefined();
1359+
expect(data.stats.currentStreak).toBeDefined();
1360+
expect(data.stats.longestStreak).toBeDefined();
1361+
expect(data.stats.totalContributions).toBeDefined();
1362+
expect(data.monthlyStats).toBeDefined();
1363+
expect(data.monthlyStats.currentMonthTotal).toBeDefined();
1364+
expect(data.calendar).toBeDefined();
1365+
expect(data.calendar.totalContributions).toBe(10);
1366+
expect(data.calendar.weeks).toHaveLength(2);
1367+
});
1368+
1369+
it('includes Cache-Control header in JSON response', async () => {
1370+
const response = await GET(makeRequest({ user: 'octocat', format: 'json' }));
1371+
expect(response.headers.get('Cache-Control')).toContain('s-maxage=');
1372+
});
1373+
1374+
it('includes X-Cache-Status header in JSON response', async () => {
1375+
const response = await GET(makeRequest({ user: 'octocat', format: 'json' }));
1376+
expect(response.headers.get('X-Cache-Status')).toBe('HIT');
1377+
});
1378+
1379+
it('returns SVG when format is not set (default)', async () => {
1380+
const response = await GET(makeRequest({ user: 'octocat' }));
1381+
expect(response.headers.get('Content-Type')).toBe('image/svg+xml');
1382+
});
1383+
1384+
it('falls back to SVG for invalid format values', async () => {
1385+
const response = await GET(makeRequest({ user: 'octocat', format: 'xml' }));
1386+
expect(response.headers.get('Content-Type')).toBe('image/svg+xml');
1387+
});
1388+
1389+
it('uses org name as user field when org parameter is provided', async () => {
1390+
const response = await GET(makeRequest({ user: 'octocat', org: 'github', format: 'json' }));
1391+
const data = await response.json();
1392+
expect(data.user).toBe('github');
1393+
});
1394+
});
13401395
});

app/api/streak/route.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export async function GET(request: Request) {
9494
tz: tzParam,
9595
disable_particles,
9696
glow,
97+
format,
9798
} = parseResult.data;
9899

99100
const themeName = theme || 'dark';
@@ -107,6 +108,8 @@ export async function GET(request: Request) {
107108
: year
108109
? `${year}-12-31T23:59:59Z`
109110
: undefined;
111+
const currentYear = new Date().getUTCFullYear();
112+
const isHistoricalYear = !!year && Number(year) < currentYear;
110113

111114
let timezone = 'UTC';
112115
if (tzParam) {
@@ -196,6 +199,42 @@ export async function GET(request: Request) {
196199
}
197200
}
198201

202+
// ─── JSON output mode ──────────────────────────────────────────────────
203+
if (format === 'json') {
204+
const stats = calculateStreak(calendar, timezone, undefined, grace);
205+
const monthlyStats = calculateMonthlyStats(
206+
calendar,
207+
timezone,
208+
getMonthlyReferenceDate(year, timezone)
209+
);
210+
211+
const secondsToMidnight = tzParam
212+
? getSecondsUntilMidnightInTimezone(timezone)
213+
: getSecondsUntilUTCMidnight();
214+
const cacheControl = refresh
215+
? 'no-cache, no-store, must-revalidate'
216+
: `public, s-maxage=${secondsToMidnight}, stale-while-revalidate=86400`;
217+
218+
return NextResponse.json(
219+
{
220+
user: targetEntity,
221+
stats,
222+
monthlyStats,
223+
calendar: {
224+
totalContributions: calendar.totalContributions,
225+
weeks: calendar.weeks,
226+
},
227+
},
228+
{
229+
headers: {
230+
'Cache-Control': cacheControl,
231+
'X-Cache-Status': refresh ? `BYPASS, fetched=${new Date().toISOString()}` : 'HIT',
232+
},
233+
}
234+
);
235+
}
236+
237+
// ─── SVG output mode (default) ──────────────────────────────────────────
199238
let svg = '';
200239
if (view === 'monthly') {
201240
const stats = calculateMonthlyStats(
@@ -226,7 +265,9 @@ export async function GET(request: Request) {
226265
: getSecondsUntilUTCMidnight();
227266
const cacheControl = refresh
228267
? 'no-cache, no-store, must-revalidate'
229-
: `public, s-maxage=${secondsToMidnight}, stale-while-revalidate=86400`;
268+
: isHistoricalYear
269+
? 'public, s-maxage=31536000, immutable'
270+
: `public, s-maxage=${secondsToMidnight}, stale-while-revalidate=86400`;
230271

231272
return new NextResponse(svg, {
232273
headers: {

0 commit comments

Comments
 (0)