11import { describe , it , expect , beforeEach , vi } from 'vitest' ;
22import Fastify from 'fastify' ;
3- import jwt from '@fastify/jwt' ;
43import { connectRoutes } from '../routes/connect.js' ;
5- import type { PrismaClient } from '@prisma/client' ;
64
75process . env . PUBLIC_APP_URL = 'http://localhost:3000' ;
86process . env . BACKEND_URL = 'http://localhost:3001' ;
@@ -11,9 +9,12 @@ process.env.GITHUB_CLIENT_ID = 'test-client-id';
119process . env . GITHUB_CLIENT_SECRET = 'test-client-secret' ;
1210process . env . ENCRYPTION_KEY = '12345678901234567890123456789012' ;
1311
12+ const VALID_NONCE = 'a' . repeat ( 64 ) ;
13+ const USER_ID = 'user-abc' ;
14+
1415const mockRedis = {
15- get : vi . fn ( ) ,
1616 set : vi . fn ( ) ,
17+ get : vi . fn ( ) ,
1718 del : vi . fn ( ) ,
1819} ;
1920
@@ -25,163 +26,223 @@ const mockPrisma = {
2526 } ,
2627} ;
2728
28- global . fetch = vi . fn ( ) ;
29-
30- async function buildApp ( ) {
29+ async function buildApp ( authenticatedUserId = USER_ID ) {
3130 const app = Fastify ( ) ;
32- await app . register ( jwt , { secret : 'test-secret' } ) ;
33- app . decorate ( 'prisma' , mockPrisma as unknown as PrismaClient ) ;
3431 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- }
32+ app . decorate ( 'prisma' , mockPrisma as any ) ;
33+ app . decorate ( 'authenticate' , async ( request : any ) => {
34+ request . user = { id : authenticatedUserId } ;
4235 } ) ;
43-
4436 app . register ( connectRoutes , { prefix : '/api/connect' } ) ;
4537 await app . ready ( ) ;
4638 return app ;
4739}
4840
41+ describe ( 'GET /api/connect/github' , ( ) => {
42+ beforeEach ( ( ) => vi . clearAllMocks ( ) ) ;
43+
44+ it ( 'stores nonce in redis with userId and redirects to GitHub' , async ( ) => {
45+ const app = await buildApp ( ) ;
46+ const res = await app . inject ( { method : 'GET' , url : '/api/connect/github' } ) ;
47+
48+ expect ( res . statusCode ) . toBe ( 302 ) ;
49+ expect ( res . headers . location ) . toMatch ( / ^ h t t p s : \/ \/ g i t h u b \. c o m \/ l o g i n \/ o a u t h \/ a u t h o r i z e / ) ;
50+ expect ( mockRedis . set ) . toHaveBeenCalledOnce ( ) ;
51+
52+ const [ key , value , , ttl ] = mockRedis . set . mock . calls [ 0 ] ;
53+ expect ( key ) . toMatch ( / ^ o a u t h : c o n n e c t - n o n c e : [ 0 - 9 a - f ] { 64 } $ / ) ;
54+ const stored = JSON . parse ( value ) ;
55+ expect ( stored . userId ) . toBe ( USER_ID ) ;
56+ expect ( ttl ) . toBe ( 600 ) ;
57+
58+ // nonce in redirect state must match stored key
59+ const location = res . headers . location as string ;
60+ const stateParam = new URL ( location ) . searchParams . get ( 'state' ) ;
61+ expect ( key ) . toBe ( `oauth:connect-nonce:${ stateParam } ` ) ;
62+ } ) ;
63+
64+ it ( 'state param is 64 lowercase hex chars' , async ( ) => {
65+ const app = await buildApp ( ) ;
66+ const res = await app . inject ( { method : 'GET' , url : '/api/connect/github' } ) ;
67+ const location = res . headers . location as string ;
68+ const stateParam = new URL ( location ) . searchParams . get ( 'state' ) ;
69+ expect ( stateParam ) . toMatch ( / ^ [ 0 - 9 a - f ] { 64 } $ / ) ;
70+ } ) ;
71+ } ) ;
72+
4973describe ( 'GET /api/connect/github/callback' , ( ) => {
50- beforeEach ( ( ) => {
51- vi . clearAllMocks ( ) ;
74+ beforeEach ( ( ) => vi . clearAllMocks ( ) ) ;
75+
76+ it ( 'valid flow: completes connect, writes github_follow token, redirects to settings' , async ( ) => {
77+ mockRedis . get . mockResolvedValue ( JSON . stringify ( { userId : USER_ID } ) ) ;
78+ mockRedis . del . mockResolvedValue ( 1 ) ;
79+ mockPrisma . oAuthToken . upsert . mockResolvedValue ( { } ) ;
80+
81+ global . fetch = vi . fn ( ) . mockResolvedValue ( {
82+ json : async ( ) => ( { access_token : 'gh-token' , scope : 'user:follow' } ) ,
83+ } ) as any ;
84+
85+ const app = await buildApp ( ) ;
86+ const res = await app . inject ( {
87+ method : 'GET' ,
88+ url : `/api/connect/github/callback?code=validcode&state=${ VALID_NONCE } ` ,
89+ } ) ;
90+
91+ expect ( res . statusCode ) . toBe ( 302 ) ;
92+ expect ( res . headers . location ) . toContain ( 'connected=github' ) ;
93+ expect ( mockRedis . del ) . toHaveBeenCalledWith ( `oauth:connect-nonce:${ VALID_NONCE } ` ) ;
94+ expect ( mockPrisma . oAuthToken . upsert ) . toHaveBeenCalledWith (
95+ expect . objectContaining ( {
96+ where : { userId_platform : { userId : USER_ID , platform : 'github_follow' } } ,
97+ } )
98+ ) ;
5299 } ) ;
53100
54- it ( 'redirects with missing_params if code or state is missing ' , async ( ) => {
101+ it ( 'missing code redirects with missing_params error ' , async ( ) => {
55102 const app = await buildApp ( ) ;
56-
57- // Missing code
58- let res = await app . inject ( {
103+ const res = await app . inject ( {
59104 method : 'GET' ,
60- url : ' /api/connect/github/callback?state=somestate' ,
105+ url : ` /api/connect/github/callback?state=${ VALID_NONCE } ` ,
61106 } ) ;
62107 expect ( res . statusCode ) . toBe ( 302 ) ;
63- expect ( res . headers . location ) . toBe ( 'http://localhost:3000/settings?error=missing_params' ) ;
108+ expect ( res . headers . location ) . toContain ( 'error=missing_params' ) ;
109+ expect ( mockRedis . get ) . not . toHaveBeenCalled ( ) ;
110+ } ) ;
64111
65- // Missing state
66- res = await app . inject ( {
112+ it ( 'missing state redirects with missing_params error' , async ( ) => {
113+ const app = await buildApp ( ) ;
114+ const res = await app . inject ( {
67115 method : 'GET' ,
68- url : '/api/connect/github/callback?code=somecode ' ,
116+ url : '/api/connect/github/callback?code=validcode ' ,
69117 } ) ;
70118 expect ( res . statusCode ) . toBe ( 302 ) ;
71- expect ( res . headers . location ) . toBe ( 'http://localhost:3000/settings? error=missing_params') ;
119+ expect ( res . headers . location ) . toContain ( ' error=missing_params') ;
72120 } ) ;
73121
74- it ( 'redirects with connect_failed if state is invalid/malformed ' , async ( ) => {
122+ it ( 'malformed state (too short) redirects with connect_failed ' , async ( ) => {
75123 const app = await buildApp ( ) ;
76- const invalidState = Buffer . from ( JSON . stringify ( { wrongKey : 'value' } ) ) . toString ( 'base64' ) ;
77-
78124 const res = await app . inject ( {
79125 method : 'GET' ,
80- url : ` /api/connect/github/callback?code=testcode &state=${ invalidState } ` ,
126+ url : ' /api/connect/github/callback?code=validcode &state=tooshort' ,
81127 } ) ;
82-
83128 expect ( res . statusCode ) . toBe ( 302 ) ;
84- expect ( res . headers . location ) . toBe ( 'http://localhost:3000/settings?error=connect_failed' ) ;
129+ expect ( res . headers . location ) . toContain ( 'error=connect_failed' ) ;
130+ expect ( mockRedis . get ) . not . toHaveBeenCalled ( ) ;
85131 } ) ;
86132
87- it ( 'redirects with invalid_state if nonce is not found in Redis (CSRF/Expired)' , async ( ) => {
88- mockRedis . get . mockResolvedValue ( null ) ;
133+ it ( 'malformed state (non-hex chars) redirects with connect_failed' , async ( ) => {
89134 const app = await buildApp ( ) ;
90- const validState = Buffer . from ( JSON . stringify ( { userId : 'user-1' , nonce : 'nonce-123' } ) ) . toString ( 'base64' ) ;
91-
135+ const badState = 'z' . repeat ( 64 ) ;
92136 const res = await app . inject ( {
93137 method : 'GET' ,
94- url : `/api/connect/github/callback?code=testcode &state=${ validState } ` ,
138+ url : `/api/connect/github/callback?code=validcode &state=${ badState } ` ,
95139 } ) ;
96-
97- expect ( mockRedis . get ) . toHaveBeenCalledWith ( 'oauth:nonce:nonce-123' ) ;
98140 expect ( res . statusCode ) . toBe ( 302 ) ;
99- expect ( res . headers . location ) . toBe ( 'http://localhost:3000/settings?error=invalid_state' ) ;
141+ expect ( res . headers . location ) . toContain ( 'error=connect_failed' ) ;
142+ expect ( mockRedis . get ) . not . toHaveBeenCalled ( ) ;
100143 } ) ;
101144
102- it ( 'redirects with invalid_state if Redis userId does not match state userId' , async ( ) => {
103- mockRedis . get . mockResolvedValue ( 'different-user-id' ) ;
145+ it ( 'forged base64-JSON state is rejected as malformed' , async ( ) => {
104146 const app = await buildApp ( ) ;
105- const validState = Buffer . from ( JSON . stringify ( { userId : 'user-1' , nonce : 'nonce-123' } ) ) . toString ( 'base64' ) ;
106-
147+ const forgedState = Buffer . from ( JSON . stringify ( { userId : USER_ID , nonce : 'x' . repeat ( 32 ) } ) ) . toString ( 'base64' ) ;
107148 const res = await app . inject ( {
108149 method : 'GET' ,
109- url : `/api/connect/github/callback?code=testcode &state=${ validState } ` ,
150+ url : `/api/connect/github/callback?code=validcode &state=${ forgedState } ` ,
110151 } ) ;
111-
112152 expect ( res . statusCode ) . toBe ( 302 ) ;
113- expect ( res . headers . location ) . toBe ( 'http://localhost:3000/settings?error=invalid_state' ) ;
153+ expect ( res . headers . location ) . toContain ( 'error=connect_failed' ) ;
154+ expect ( mockRedis . get ) . not . toHaveBeenCalled ( ) ;
114155 } ) ;
115156
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' } )
157+ it ( 'unknown nonce (not in Redis) redirects with connect_failed' , async ( ) => {
158+ mockRedis . get . mockResolvedValue ( null ) ;
159+ const app = await buildApp ( ) ;
160+ const res = await app . inject ( {
161+ method : 'GET' ,
162+ url : `/api/connect/github/callback?code=validcode&state=${ VALID_NONCE } ` ,
120163 } ) ;
121- mockPrisma . oAuthToken . upsert . mockResolvedValue ( { } ) ;
164+ expect ( res . statusCode ) . toBe ( 302 ) ;
165+ expect ( res . headers . location ) . toContain ( 'error=connect_failed' ) ;
166+ expect ( mockRedis . del ) . not . toHaveBeenCalled ( ) ;
167+ } ) ;
122168
169+ it ( 'expired nonce (Redis returns null) redirects with connect_failed' , async ( ) => {
170+ mockRedis . get . mockResolvedValue ( null ) ;
123171 const app = await buildApp ( ) ;
124- const validState = Buffer . from ( JSON . stringify ( { userId : 'user-1' , nonce : 'web_nonce-123' } ) ) . toString ( 'base64' ) ;
125-
126172 const res = await app . inject ( {
127173 method : 'GET' ,
128- url : `/api/connect/github/callback?code=testcode &state=${ validState } ` ,
174+ url : `/api/connect/github/callback?code=validcode &state=${ VALID_NONCE } ` ,
129175 } ) ;
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- } ) ) ;
144-
145- // Redirects to web success
146176 expect ( res . statusCode ) . toBe ( 302 ) ;
147- expect ( res . headers . location ) . toBe ( 'http://localhost:3000/settings?connected=github ') ;
177+ expect ( res . headers . location ) . toContain ( 'error=connect_failed ') ;
148178 } ) ;
149179
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' } )
180+ it ( 'forged state (random nonce not stored in Redis) redirects with connect_failed' , async ( ) => {
181+ mockRedis . get . mockResolvedValue ( null ) ;
182+ const forgedNonce = 'b' . repeat ( 64 ) ;
183+ const app = await buildApp ( ) ;
184+ const res = await app . inject ( {
185+ method : 'GET' ,
186+ url : `/api/connect/github/callback?code=validcode&state=${ forgedNonce } ` ,
154187 } ) ;
188+ expect ( res . statusCode ) . toBe ( 302 ) ;
189+ expect ( res . headers . location ) . toContain ( 'error=connect_failed' ) ;
190+ } ) ;
191+
192+ it ( 'replay attack: second use of same nonce fails after first succeeds' , async ( ) => {
193+ mockRedis . del . mockResolvedValue ( 1 ) ;
155194 mockPrisma . oAuthToken . upsert . mockResolvedValue ( { } ) ;
156195
196+ global . fetch = vi . fn ( ) . mockResolvedValue ( {
197+ json : async ( ) => ( { access_token : 'gh-token' , scope : 'user:follow' } ) ,
198+ } ) as any ;
199+
200+ // First call succeeds
201+ mockRedis . get . mockResolvedValueOnce ( JSON . stringify ( { userId : USER_ID } ) ) ;
202+ const app = await buildApp ( ) ;
203+ const res1 = await app . inject ( {
204+ method : 'GET' ,
205+ url : `/api/connect/github/callback?code=code1&state=${ VALID_NONCE } ` ,
206+ } ) ;
207+ expect ( res1 . statusCode ) . toBe ( 302 ) ;
208+ expect ( res1 . headers . location ) . toContain ( 'connected=github' ) ;
209+
210+ // Second call: nonce deleted, Redis returns null
211+ mockRedis . get . mockResolvedValueOnce ( null ) ;
212+ const res2 = await app . inject ( {
213+ method : 'GET' ,
214+ url : `/api/connect/github/callback?code=code2&state=${ VALID_NONCE } ` ,
215+ } ) ;
216+ expect ( res2 . statusCode ) . toBe ( 302 ) ;
217+ expect ( res2 . headers . location ) . toContain ( 'error=connect_failed' ) ;
218+ } ) ;
219+
220+ it ( 'corrupt nonce data in Redis redirects with connect_failed' , async ( ) => {
221+ mockRedis . get . mockResolvedValue ( 'not-valid-json{{{' ) ;
222+ mockRedis . del . mockResolvedValue ( 1 ) ;
157223 const app = await buildApp ( ) ;
158- const validState = Buffer . from ( JSON . stringify ( { userId : 'user-1' , nonce : 'mobile_nonce-123' } ) ) . toString ( 'base64' ) ;
159-
160224 const res = await app . inject ( {
161225 method : 'GET' ,
162- url : `/api/connect/github/callback?code=testcode &state=${ validState } ` ,
226+ url : `/api/connect/github/callback?code=validcode &state=${ VALID_NONCE } ` ,
163227 } ) ;
164-
165228 expect ( res . statusCode ) . toBe ( 302 ) ;
166- expect ( res . headers . location ) . toBe ( 'devcard://connect?connected=github ') ;
229+ expect ( res . headers . location ) . toContain ( 'error=connect_failed ') ;
167230 } ) ;
168231
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- } ) ;
232+ it ( 'GitHub token exchange failure redirects with connect_failed' , async ( ) => {
233+ mockRedis . get . mockResolvedValue ( JSON . stringify ( { userId : USER_ID } ) ) ;
234+ mockRedis . del . mockResolvedValue ( 1 ) ;
235+
236+ global . fetch = vi . fn ( ) . mockResolvedValue ( {
237+ json : async ( ) => ( { error : 'bad_verification_code' } ) ,
238+ } ) as any ;
174239
175240 const app = await buildApp ( ) ;
176- const validState = Buffer . from ( JSON . stringify ( { userId : 'user-1' , nonce : 'nonce-123' } ) ) . toString ( 'base64' ) ;
177-
178241 const res = await app . inject ( {
179242 method : 'GET' ,
180- url : `/api/connect/github/callback?code=testcode &state=${ validState } ` ,
243+ url : `/api/connect/github/callback?code=badcode &state=${ VALID_NONCE } ` ,
181244 } ) ;
182-
183- expect ( mockPrisma . oAuthToken . upsert ) . not . toHaveBeenCalled ( ) ;
184245 expect ( res . statusCode ) . toBe ( 302 ) ;
185- expect ( res . headers . location ) . toBe ( 'http://localhost:3000/settings? error=connect_failed') ;
246+ expect ( res . headers . location ) . toContain ( ' error=connect_failed') ;
186247 } ) ;
187- } ) ;
248+ } ) ;
0 commit comments