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