Skip to content

Commit 01981a0

Browse files
authored
test(connect): add regression testing for oauth callback flow (#411)
1 parent 47856c0 commit 01981a0

1 file changed

Lines changed: 177 additions & 29 deletions

File tree

Lines changed: 177 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,187 @@
1-
import { describe, it, expect } from 'vitest';
1+
import { describe, it, expect, beforeEach, vi } from 'vitest';
2+
import Fastify from 'fastify';
3+
import jwt from '@fastify/jwt';
4+
import { connectRoutes } from '../routes/connect.js';
5+
import type { PrismaClient } from '@prisma/client';
26

3-
// Mock test for GitHub OAuth callback state validation
4-
// Note: This test verifies the expected behavior of the
5-
// /api/connect/github/callback endpoint when invalid or
6-
// malformed OAuth state values are received.
7-
//
8-
// The implementation in connect.ts now:
9-
// - safely parses OAuth state via parseGoogleState()
10-
// - validates required fields (userId + nonce)
11-
// - redirects invalid callbacks safely
12-
//
13-
// Security note:
14-
// OAuth state validation helps prevent tampered callback
15-
// requests and malformed state payload attacks.
7+
process.env.PUBLIC_APP_URL = 'http://localhost:3000';
8+
process.env.BACKEND_URL = 'http://localhost:3001';
9+
process.env.MOBILE_REDIRECT_URI = 'devcard://connect';
10+
process.env.GITHUB_CLIENT_ID = 'test-client-id';
11+
process.env.GITHUB_CLIENT_SECRET = 'test-client-secret';
12+
process.env.ENCRYPTION_KEY = '12345678901234567890123456789012';
1613

17-
describe('GET /api/connect/github/callback - Invalid OAuth State', () => {
14+
const mockRedis = {
15+
get: vi.fn(),
16+
set: vi.fn(),
17+
del: vi.fn(),
18+
};
1819

19-
it('should redirect with connect_failed when state is invalid', async () => {
20-
// Expected behavior:
21-
// parseGoogleState('invalid_state') -> null
22-
// reply.redirect(`${PUBLIC_APP_URL}/settings?error=connect_failed`)
20+
const mockPrisma = {
21+
oAuthToken: {
22+
findMany: vi.fn(),
23+
upsert: vi.fn(),
24+
delete: vi.fn(),
25+
},
26+
};
2327

24-
expect(true).toBe(true);
28+
global.fetch = vi.fn();
29+
30+
async function buildApp() {
31+
const app = Fastify();
32+
await app.register(jwt, { secret: 'test-secret' });
33+
app.decorate('prisma', mockPrisma as unknown as PrismaClient);
34+
app.decorate('redis', mockRedis as any);
35+
36+
app.decorate('authenticate', async (request: any, reply: any) => {
37+
try {
38+
await request.jwtVerify();
39+
} catch (err) {
40+
reply.status(401).send({ error: 'Unauthorized' });
41+
}
42+
});
43+
44+
app.register(connectRoutes, { prefix: '/api/connect' });
45+
await app.ready();
46+
return app;
47+
}
48+
49+
describe('GET /api/connect/github/callback', () => {
50+
beforeEach(() => {
51+
vi.clearAllMocks();
52+
});
53+
54+
it('redirects with missing_params if code or state is missing', async () => {
55+
const app = await buildApp();
56+
57+
// Missing code
58+
let res = await app.inject({
59+
method: 'GET',
60+
url: '/api/connect/github/callback?state=somestate',
61+
});
62+
expect(res.statusCode).toBe(302);
63+
expect(res.headers.location).toBe('http://localhost:3000/settings?error=missing_params');
64+
65+
// Missing state
66+
res = await app.inject({
67+
method: 'GET',
68+
url: '/api/connect/github/callback?code=somecode',
69+
});
70+
expect(res.statusCode).toBe(302);
71+
expect(res.headers.location).toBe('http://localhost:3000/settings?error=missing_params');
72+
});
73+
74+
it('redirects with connect_failed if state is invalid/malformed', async () => {
75+
const app = await buildApp();
76+
const invalidState = Buffer.from(JSON.stringify({ wrongKey: 'value' })).toString('base64');
77+
78+
const res = await app.inject({
79+
method: 'GET',
80+
url: `/api/connect/github/callback?code=testcode&state=${invalidState}`,
81+
});
82+
83+
expect(res.statusCode).toBe(302);
84+
expect(res.headers.location).toBe('http://localhost:3000/settings?error=connect_failed');
85+
});
86+
87+
it('redirects with invalid_state if nonce is not found in Redis (CSRF/Expired)', async () => {
88+
mockRedis.get.mockResolvedValue(null);
89+
const app = await buildApp();
90+
const validState = Buffer.from(JSON.stringify({ userId: 'user-1', nonce: 'nonce-123' })).toString('base64');
91+
92+
const res = await app.inject({
93+
method: 'GET',
94+
url: `/api/connect/github/callback?code=testcode&state=${validState}`,
95+
});
96+
97+
expect(mockRedis.get).toHaveBeenCalledWith('oauth:nonce:nonce-123');
98+
expect(res.statusCode).toBe(302);
99+
expect(res.headers.location).toBe('http://localhost:3000/settings?error=invalid_state');
100+
});
101+
102+
it('redirects with invalid_state if Redis userId does not match state userId', async () => {
103+
mockRedis.get.mockResolvedValue('different-user-id');
104+
const app = await buildApp();
105+
const validState = Buffer.from(JSON.stringify({ userId: 'user-1', nonce: 'nonce-123' })).toString('base64');
106+
107+
const res = await app.inject({
108+
method: 'GET',
109+
url: `/api/connect/github/callback?code=testcode&state=${validState}`,
110+
});
111+
112+
expect(res.statusCode).toBe(302);
113+
expect(res.headers.location).toBe('http://localhost:3000/settings?error=invalid_state');
114+
});
115+
116+
it('successfully exchanges code, upserts token, and redirects on valid flow (Web)', async () => {
117+
mockRedis.get.mockResolvedValue('user-1');
118+
(global.fetch as any).mockResolvedValue({
119+
json: vi.fn().mockResolvedValue({ access_token: 'github-access-token', scope: 'user:follow' })
25120
});
121+
mockPrisma.oAuthToken.upsert.mockResolvedValue({});
122+
123+
const app = await buildApp();
124+
const validState = Buffer.from(JSON.stringify({ userId: 'user-1', nonce: 'web_nonce-123' })).toString('base64');
125+
126+
const res = await app.inject({
127+
method: 'GET',
128+
url: `/api/connect/github/callback?code=testcode&state=${validState}`,
129+
});
130+
131+
// Nonce should be deleted immediately
132+
expect(mockRedis.del).toHaveBeenCalledWith('oauth:nonce:web_nonce-123');
133+
134+
// Code exchange should be triggered
135+
expect(global.fetch).toHaveBeenCalledWith('https://github.com/login/oauth/access_token', expect.objectContaining({
136+
method: 'POST',
137+
body: expect.stringContaining('testcode')
138+
}));
139+
140+
// Upsert should be called
141+
expect(mockPrisma.oAuthToken.upsert).toHaveBeenCalledWith(expect.objectContaining({
142+
where: { userId_platform: { userId: 'user-1', platform: 'github_follow' } }
143+
}));
26144

27-
it('should reject malformed oauth state payloads', async () => {
28-
// Example malformed payload:
29-
// { invalid: true }
30-
//
31-
// Expected:
32-
// - missing userId
33-
// - missing nonce
34-
// - redirect to connect_failed
145+
// Redirects to web success
146+
expect(res.statusCode).toBe(302);
147+
expect(res.headers.location).toBe('http://localhost:3000/settings?connected=github');
148+
});
35149

36-
expect(true).toBe(true);
150+
it('redirects to mobile scheme if nonce starts with mobile_', async () => {
151+
mockRedis.get.mockResolvedValue('user-1');
152+
(global.fetch as any).mockResolvedValue({
153+
json: vi.fn().mockResolvedValue({ access_token: 'github-access-token', scope: 'user:follow' })
37154
});
155+
mockPrisma.oAuthToken.upsert.mockResolvedValue({});
38156

157+
const app = await buildApp();
158+
const validState = Buffer.from(JSON.stringify({ userId: 'user-1', nonce: 'mobile_nonce-123' })).toString('base64');
159+
160+
const res = await app.inject({
161+
method: 'GET',
162+
url: `/api/connect/github/callback?code=testcode&state=${validState}`,
163+
});
164+
165+
expect(res.statusCode).toBe(302);
166+
expect(res.headers.location).toBe('devcard://connect?connected=github');
167+
});
168+
169+
it('redirects with connect_failed if token exchange returns an error', async () => {
170+
mockRedis.get.mockResolvedValue('user-1');
171+
(global.fetch as any).mockResolvedValue({
172+
json: vi.fn().mockResolvedValue({ error: 'bad_verification_code' })
173+
});
174+
175+
const app = await buildApp();
176+
const validState = Buffer.from(JSON.stringify({ userId: 'user-1', nonce: 'nonce-123' })).toString('base64');
177+
178+
const res = await app.inject({
179+
method: 'GET',
180+
url: `/api/connect/github/callback?code=testcode&state=${validState}`,
181+
});
182+
183+
expect(mockPrisma.oAuthToken.upsert).not.toHaveBeenCalled();
184+
expect(res.statusCode).toBe(302);
185+
expect(res.headers.location).toBe('http://localhost:3000/settings?error=connect_failed');
186+
});
39187
});

0 commit comments

Comments
 (0)