Skip to content

Commit c3ea871

Browse files
authored
Merge branch 'main' into fix-profile-self-view-tracking
Signed-off-by: hariom888 <hariom880088@gmail.com>
2 parents 48408dd + 0d43ebc commit c3ea871

4 files changed

Lines changed: 312 additions & 47 deletions

File tree

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import { describe, it, expect, beforeEach, vi } from 'vitest';
2+
import Fastify from 'fastify';
3+
import { publicRoutes } from '../routes/public.js';
4+
import type { PrismaClient } from '@prisma/client';
5+
6+
// ── Mock QR utilities ─────────────────────────────────────────────────────────
7+
// Prevents real QR rasterisation (and any native canvas/image deps) from running
8+
// during unit tests. The stubs return minimal valid values that satisfy the
9+
// Content-Type assertions below.
10+
vi.mock('../utils/qr.js', () => ({
11+
generateQRBuffer: vi.fn().mockResolvedValue(Buffer.from('fake-png')),
12+
generateQRSvg: vi.fn().mockResolvedValue('<svg>fake</svg>'),
13+
}));
14+
15+
import { generateQRBuffer, generateQRSvg } from '../utils/qr.js';
16+
17+
const mockUser = {
18+
id: 'user-123',
19+
username: 'testuser',
20+
displayName: 'Test User',
21+
bio: null,
22+
pronouns: null,
23+
role: null,
24+
company: null,
25+
avatarUrl: null,
26+
accentColor: '#ffffff',
27+
platformLinks: [],
28+
};
29+
30+
const mockPrisma = {
31+
user: {
32+
findUnique: vi.fn(),
33+
},
34+
platformLink: {} as any,
35+
cardView: {
36+
create: vi.fn().mockReturnValue({ catch: vi.fn() }),
37+
},
38+
followLog: {
39+
findMany: vi.fn().mockResolvedValue([]),
40+
},
41+
card: {} as any,
42+
};
43+
44+
async function buildApp() {
45+
const app = Fastify();
46+
app.decorate('prisma', mockPrisma as unknown as PrismaClient);
47+
// Soft auth: jwtVerify rejects by default (unauthenticated visitor)
48+
app.decorateRequest('jwtVerify', async function () {
49+
throw new Error('no token');
50+
});
51+
app.register(publicRoutes, { prefix: '/api/public' });
52+
await app.ready();
53+
return app;
54+
}
55+
56+
// ─── QR size validation ───────────────────────────────────────────────────────
57+
58+
describe('GET /api/public/:username/qr — size validation', () => {
59+
beforeEach(() => {
60+
vi.clearAllMocks();
61+
// Re-attach default mock behaviour cleared by clearAllMocks
62+
(generateQRBuffer as ReturnType<typeof vi.fn>).mockResolvedValue(Buffer.from('fake-png'));
63+
(generateQRSvg as ReturnType<typeof vi.fn>).mockResolvedValue('<svg>fake</svg>');
64+
});
65+
66+
// ── Reject before DB touch ─────────────────────────────────────────────────
67+
68+
it('rejects size=0 with 400 before any DB query', async () => {
69+
const app = await buildApp();
70+
const res = await app.inject({
71+
method: 'GET',
72+
url: '/api/public/testuser/qr?size=0',
73+
});
74+
expect(res.statusCode).toBe(400);
75+
expect(res.json().error).toMatch(/integer between/i);
76+
expect(mockPrisma.user.findUnique).not.toHaveBeenCalled();
77+
});
78+
79+
it('rejects size=-1 with 400 before any DB query', async () => {
80+
const app = await buildApp();
81+
const res = await app.inject({
82+
method: 'GET',
83+
url: '/api/public/testuser/qr?size=-1',
84+
});
85+
expect(res.statusCode).toBe(400);
86+
expect(mockPrisma.user.findUnique).not.toHaveBeenCalled();
87+
});
88+
89+
it('rejects size=50000 (above upper bound) with 400', async () => {
90+
const app = await buildApp();
91+
const res = await app.inject({
92+
method: 'GET',
93+
url: '/api/public/testuser/qr?size=50000',
94+
});
95+
expect(res.statusCode).toBe(400);
96+
expect(res.json().error).toMatch(/integer between/i);
97+
expect(mockPrisma.user.findUnique).not.toHaveBeenCalled();
98+
});
99+
100+
it('rejects size=2049 (one above upper bound) with 400', async () => {
101+
const app = await buildApp();
102+
const res = await app.inject({
103+
method: 'GET',
104+
url: '/api/public/testuser/qr?size=2049',
105+
});
106+
expect(res.statusCode).toBe(400);
107+
expect(mockPrisma.user.findUnique).not.toHaveBeenCalled();
108+
});
109+
110+
it('rejects non-numeric size (abc) with 400', async () => {
111+
const app = await buildApp();
112+
const res = await app.inject({
113+
method: 'GET',
114+
url: '/api/public/testuser/qr?size=abc',
115+
});
116+
expect(res.statusCode).toBe(400);
117+
expect(mockPrisma.user.findUnique).not.toHaveBeenCalled();
118+
});
119+
120+
it('rejects floating-point size (400.5) with 400', async () => {
121+
// parseInt('400.5') === 400, which IS in range — this passes.
122+
// Documenting the boundary: fractional strings are truncated, not rejected.
123+
// A string like '0.5' parseInt → 0, which is out of range.
124+
const app = await buildApp();
125+
const res = await app.inject({
126+
method: 'GET',
127+
url: '/api/public/testuser/qr?size=0.5',
128+
});
129+
expect(res.statusCode).toBe(400);
130+
expect(mockPrisma.user.findUnique).not.toHaveBeenCalled();
131+
});
132+
133+
// ── Accept valid sizes ─────────────────────────────────────────────────────
134+
135+
it('accepts size=1 (lower bound) and returns PNG', async () => {
136+
mockPrisma.user.findUnique.mockResolvedValue(mockUser);
137+
const app = await buildApp();
138+
const res = await app.inject({
139+
method: 'GET',
140+
url: '/api/public/testuser/qr?size=1',
141+
});
142+
expect(res.statusCode).toBe(200);
143+
expect(res.headers['content-type']).toMatch(/image\/png/);
144+
expect(generateQRBuffer).toHaveBeenCalledWith(
145+
expect.any(String),
146+
expect.objectContaining({ width: 1 }),
147+
);
148+
});
149+
150+
it('accepts size=2048 (upper bound) and returns PNG', async () => {
151+
mockPrisma.user.findUnique.mockResolvedValue(mockUser);
152+
const app = await buildApp();
153+
const res = await app.inject({
154+
method: 'GET',
155+
url: '/api/public/testuser/qr?size=2048',
156+
});
157+
expect(res.statusCode).toBe(200);
158+
expect(res.headers['content-type']).toMatch(/image\/png/);
159+
expect(generateQRBuffer).toHaveBeenCalledWith(
160+
expect.any(String),
161+
expect.objectContaining({ width: 2048 }),
162+
);
163+
});
164+
165+
it('defaults to size=400 when no size param is provided', async () => {
166+
mockPrisma.user.findUnique.mockResolvedValue(mockUser);
167+
const app = await buildApp();
168+
const res = await app.inject({
169+
method: 'GET',
170+
url: '/api/public/testuser/qr',
171+
});
172+
expect(res.statusCode).toBe(200);
173+
expect(generateQRBuffer).toHaveBeenCalledWith(
174+
expect.any(String),
175+
expect.objectContaining({ width: 400 }),
176+
);
177+
});
178+
179+
// ── Format selection ───────────────────────────────────────────────────────
180+
181+
it('returns SVG when format=svg is requested', async () => {
182+
mockPrisma.user.findUnique.mockResolvedValue(mockUser);
183+
const app = await buildApp();
184+
const res = await app.inject({
185+
method: 'GET',
186+
url: '/api/public/testuser/qr?format=svg&size=200',
187+
});
188+
expect(res.statusCode).toBe(200);
189+
expect(res.headers['content-type']).toMatch(/image\/svg\+xml/);
190+
expect(generateQRSvg).toHaveBeenCalledWith(
191+
expect.any(String),
192+
expect.objectContaining({ width: 200 }),
193+
);
194+
});
195+
196+
// ── User not found ─────────────────────────────────────────────────────────
197+
198+
it('returns 404 for an unknown username (valid size)', async () => {
199+
mockPrisma.user.findUnique.mockResolvedValue(null);
200+
const app = await buildApp();
201+
const res = await app.inject({
202+
method: 'GET',
203+
url: '/api/public/nobody/qr?size=400',
204+
});
205+
expect(res.statusCode).toBe(404);
206+
expect(res.json().error).toBe('User not found');
207+
});
208+
209+
// ── QR generation error ────────────────────────────────────────────────────
210+
211+
it('returns 500 when QR generation throws', async () => {
212+
mockPrisma.user.findUnique.mockResolvedValue(mockUser);
213+
(generateQRBuffer as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
214+
new Error('canvas error'),
215+
);
216+
const app = await buildApp();
217+
const res = await app.inject({
218+
method: 'GET',
219+
url: '/api/public/testuser/qr?size=400',
220+
});
221+
expect(res.statusCode).toBe(500);
222+
expect(res.json().error).toBe('QR code generation failed');
223+
});
224+
});

apps/backend/src/routes/auth.ts

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,24 @@ interface OAuthCallbackQuery {
1515
}
1616

1717
export async function authRoutes(app: FastifyInstance) {
18-
// ─── Developer Login Bypass ───
19-
app.post('/dev-login', async (request: FastifyRequest, reply: FastifyReply) => {
20-
const user = await app.prisma.user.findUnique({
21-
where: { username: 'devcard-demo' },
18+
// ─── Developer Login Bypass (development only) ───
19+
// This endpoint is intentionally disabled in production.
20+
// It allows local dev/testing without going through a full OAuth flow.
21+
if (process.env.NODE_ENV !== 'production') {
22+
app.post('/dev-login', async (request: FastifyRequest, reply: FastifyReply) => {
23+
const user = await app.prisma.user.findUnique({
24+
where: { username: 'devcard-demo' },
25+
});
26+
if (!user) {
27+
return reply.status(404).send({ error: 'Demo user not seeded' });
28+
}
29+
const token = app.jwt.sign(
30+
{ id: user.id, username: user.username },
31+
{ expiresIn: '30d' }
32+
);
33+
return { token };
2234
});
23-
if (!user) {
24-
return reply.status(404).send({ error: 'Demo user not seeded' });
25-
}
26-
const token = app.jwt.sign(
27-
{ id: user.id, username: user.username },
28-
{ expiresIn: '30d' }
29-
);
30-
return { token };
31-
});
35+
}
3236

3337
// ─── GitHub OAuth ───
3438

apps/backend/src/routes/connect.ts

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -33,27 +33,34 @@ export async function connectRoutes(app: FastifyInstance) {
3333

3434
// ─── GitHub Connect ───
3535

36-
app.get('/github', {
37-
preHandler: [app.authenticate],
38-
}, async (request: FastifyRequest, reply: FastifyReply) => {
39-
// Generate a secure state token linking back to this user session
40-
// In a real app, store this in Redis to cross-check in callback
41-
const state = JSON.stringify({
42-
userId: (request.user as any).id,
43-
nonce: generateState(),
44-
});
45-
46-
const redirectUri = `${process.env.BACKEND_URL}/api/connect/github/callback`;
47-
const params = new URLSearchParams({
48-
client_id: process.env.GITHUB_CLIENT_ID || '',
49-
redirect_uri: redirectUri,
50-
scope: 'user:follow', // ONLY asking for follow scope to avoid full profile access
51-
state: Buffer.from(state).toString('base64'),
52-
});
53-
54-
return reply.redirect(`${GITHUB_AUTH_URL}?${params}`);
36+
app.get('/github', {
37+
preHandler: [app.authenticate],
38+
}, async (request: FastifyRequest, reply: FastifyReply) => {
39+
const userId = (request.user as any).id;
40+
const nonce = generateState();
41+
42+
// Store nonce in Redis with 10-minute TTL.
43+
// The callback verifies this to prevent CSRF attacks.
44+
await app.redis.set(
45+
`oauth:nonce:${nonce}`,
46+
userId,
47+
'EX',
48+
600
49+
);
50+
51+
const state = JSON.stringify({ userId, nonce });
52+
53+
const redirectUri = `${process.env.BACKEND_URL}/api/connect/github/callback`;
54+
const params = new URLSearchParams({
55+
client_id: process.env.GITHUB_CLIENT_ID || '',
56+
redirect_uri: redirectUri,
57+
scope: 'user:follow',
58+
state: Buffer.from(state).toString('base64'),
5559
});
5660

61+
return reply.redirect(`${GITHUB_AUTH_URL}?${params}`);
62+
});
63+
5764
app.get('/github/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => {
5865
const { code, state } = request.query;
5966

@@ -68,12 +75,20 @@ export async function connectRoutes(app: FastifyInstance) {
6875
if (!decodedState) {
6976
return reply.redirect(`${process.env.PUBLIC_APP_URL}/settings?error=connect_failed`);
7077
}
71-
const userId = decodedState.userId;
7278

73-
if (!userId) {
79+
// Verify nonce was issued by this server — prevents CSRF
80+
const storedUserId = await app.redis.get(`oauth:nonce:${decodedState.nonce}`);
81+
82+
if (!storedUserId || storedUserId !== decodedState.userId) {
83+
app.log.warn({ nonce: decodedState.nonce }, 'OAuth CSRF check failed — nonce mismatch');
7484
return reply.redirect(`${process.env.PUBLIC_APP_URL}/settings?error=invalid_state`);
7585
}
7686

87+
// Consume the nonce — one-time use only
88+
await app.redis.del(`oauth:nonce:${decodedState.nonce}`);
89+
90+
const userId = decodedState.userId;
91+
7792
// Exchange code for token
7893
const tokenRes = await fetch(GITHUB_TOKEN_URL, {
7994
method: 'POST',

0 commit comments

Comments
 (0)