1- import { describe , it , expect } from 'vitest' ;
1+ import { describe , it , expect , beforeEach , vi } from 'vitest' ;
2+ import Fastify from 'fastify' ;
3+ import { connectRoutes } from '../routes/connect.js' ;
24
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.
5+ const VALID_NONCE = 'a' . repeat ( 64 ) ;
6+ const USER_ID = 'user-abc' ;
167
17- describe ( 'GET /api/connect/github/callback - Invalid OAuth State' , ( ) => {
8+ const mockRedis = {
9+ set : vi . fn ( ) ,
10+ get : vi . fn ( ) ,
11+ del : vi . fn ( ) ,
12+ } ;
1813
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`)
14+ const mockPrisma = {
15+ oAuthToken : {
16+ findMany : vi . fn ( ) ,
17+ upsert : vi . fn ( ) ,
18+ delete : vi . fn ( ) ,
19+ } ,
20+ } ;
2321
24- expect ( true ) . toBe ( true ) ;
22+ const mockEncryption = {
23+ encrypt : vi . fn ( ) . mockReturnValue ( 'encrypted-token' ) ,
24+ } ;
25+
26+ async function buildApp ( authenticatedUserId = USER_ID ) {
27+ const app = Fastify ( ) ;
28+ app . decorate ( 'redis' , mockRedis as any ) ;
29+ app . decorate ( 'prisma' , mockPrisma as any ) ;
30+ app . decorate ( 'encryption' , mockEncryption as any ) ;
31+ app . decorate ( 'authenticate' , async ( request : any ) => {
32+ request . user = { id : authenticatedUserId } ;
33+ } ) ;
34+ app . register ( connectRoutes , { prefix : '/api/connect' } ) ;
35+ await app . ready ( ) ;
36+ return app ;
37+ }
38+
39+ describe ( 'GET /api/connect/github' , ( ) => {
40+ beforeEach ( ( ) => vi . clearAllMocks ( ) ) ;
41+
42+ it ( 'stores nonce in redis with userId and redirects to GitHub' , async ( ) => {
43+ const app = await buildApp ( ) ;
44+ const res = await app . inject ( { method : 'GET' , url : '/api/connect/github' } ) ;
45+
46+ expect ( res . statusCode ) . toBe ( 302 ) ;
47+ 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 / ) ;
48+ expect ( mockRedis . set ) . toHaveBeenCalledOnce ( ) ;
49+
50+ const [ key , value , , ttl ] = mockRedis . set . mock . calls [ 0 ] ;
51+ 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 } $ / ) ;
52+ const stored = JSON . parse ( value ) ;
53+ expect ( stored . userId ) . toBe ( USER_ID ) ;
54+ expect ( ttl ) . toBe ( 600 ) ;
55+
56+ // nonce in redirect state must match stored key
57+ const location = res . headers . location as string ;
58+ const stateParam = new URL ( location ) . searchParams . get ( 'state' ) ;
59+ expect ( key ) . toBe ( `oauth:connect-nonce:${ stateParam } ` ) ;
60+ } ) ;
61+
62+ it ( 'state param is 64 lowercase hex chars' , async ( ) => {
63+ const app = await buildApp ( ) ;
64+ const res = await app . inject ( { method : 'GET' , url : '/api/connect/github' } ) ;
65+ const location = res . headers . location as string ;
66+ const stateParam = new URL ( location ) . searchParams . get ( 'state' ) ;
67+ expect ( stateParam ) . toMatch ( / ^ [ 0 - 9 a - f ] { 64 } $ / ) ;
68+ } ) ;
69+ } ) ;
70+
71+ describe ( 'GET /api/connect/github/callback' , ( ) => {
72+ beforeEach ( ( ) => vi . clearAllMocks ( ) ) ;
73+
74+ it ( 'valid flow: completes connect and redirects to settings' , async ( ) => {
75+ mockRedis . get . mockResolvedValue ( JSON . stringify ( { userId : USER_ID } ) ) ;
76+ mockRedis . del . mockResolvedValue ( 1 ) ;
77+ mockPrisma . oAuthToken . upsert . mockResolvedValue ( { } ) ;
78+
79+ global . fetch = vi . fn ( ) . mockResolvedValue ( {
80+ json : async ( ) => ( { access_token : 'gh-token' , scope : 'user:follow' } ) ,
81+ } ) as any ;
82+
83+ const app = await buildApp ( ) ;
84+ const res = await app . inject ( {
85+ method : 'GET' ,
86+ url : `/api/connect/github/callback?code=validcode&state=${ VALID_NONCE } ` ,
87+ } ) ;
88+
89+ expect ( res . statusCode ) . toBe ( 302 ) ;
90+ expect ( res . headers . location ) . toContain ( 'connected=github' ) ;
91+ expect ( mockRedis . del ) . toHaveBeenCalledWith ( `oauth:connect-nonce:${ VALID_NONCE } ` ) ;
92+ } ) ;
93+
94+ it ( 'missing code redirects with missing_params error' , async ( ) => {
95+ const app = await buildApp ( ) ;
96+ const res = await app . inject ( {
97+ method : 'GET' ,
98+ url : `/api/connect/github/callback?state=${ VALID_NONCE } ` ,
99+ } ) ;
100+ expect ( res . statusCode ) . toBe ( 302 ) ;
101+ expect ( res . headers . location ) . toContain ( 'error=missing_params' ) ;
102+ expect ( mockRedis . get ) . not . toHaveBeenCalled ( ) ;
103+ } ) ;
104+
105+ it ( 'missing state redirects with missing_params error' , async ( ) => {
106+ const app = await buildApp ( ) ;
107+ const res = await app . inject ( {
108+ method : 'GET' ,
109+ url : '/api/connect/github/callback?code=validcode' ,
110+ } ) ;
111+ expect ( res . statusCode ) . toBe ( 302 ) ;
112+ expect ( res . headers . location ) . toContain ( 'error=missing_params' ) ;
113+ } ) ;
114+
115+ it ( 'malformed state (too short) redirects with connect_failed' , async ( ) => {
116+ const app = await buildApp ( ) ;
117+ const res = await app . inject ( {
118+ method : 'GET' ,
119+ url : '/api/connect/github/callback?code=validcode&state=tooshort' ,
25120 } ) ;
121+ expect ( res . statusCode ) . toBe ( 302 ) ;
122+ expect ( res . headers . location ) . toContain ( 'error=connect_failed' ) ;
123+ expect ( mockRedis . get ) . not . toHaveBeenCalled ( ) ;
124+ } ) ;
26125
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
126+ it ( 'malformed state (non-hex chars) redirects with connect_failed' , async ( ) => {
127+ const app = await buildApp ( ) ;
128+ const badState = 'z' . repeat ( 64 ) ;
129+ const res = await app . inject ( {
130+ method : 'GET' ,
131+ url : `/api/connect/github/callback?code=validcode&state=${ badState } ` ,
132+ } ) ;
133+ expect ( res . statusCode ) . toBe ( 302 ) ;
134+ expect ( res . headers . location ) . toContain ( 'error=connect_failed' ) ;
135+ expect ( mockRedis . get ) . not . toHaveBeenCalled ( ) ;
136+ } ) ;
35137
36- expect ( true ) . toBe ( true ) ;
138+ it ( 'unknown nonce (not in Redis) redirects with connect_failed' , async ( ) => {
139+ mockRedis . get . mockResolvedValue ( null ) ;
140+ const app = await buildApp ( ) ;
141+ const res = await app . inject ( {
142+ method : 'GET' ,
143+ url : `/api/connect/github/callback?code=validcode&state=${ VALID_NONCE } ` ,
37144 } ) ;
145+ expect ( res . statusCode ) . toBe ( 302 ) ;
146+ expect ( res . headers . location ) . toContain ( 'error=connect_failed' ) ;
147+ expect ( mockRedis . del ) . not . toHaveBeenCalled ( ) ;
148+ } ) ;
38149
39- } ) ;
150+ it ( 'expired nonce (Redis returns null) redirects with connect_failed' , async ( ) => {
151+ mockRedis . get . mockResolvedValue ( null ) ;
152+ const app = await buildApp ( ) ;
153+ const res = await app . inject ( {
154+ method : 'GET' ,
155+ url : `/api/connect/github/callback?code=validcode&state=${ VALID_NONCE } ` ,
156+ } ) ;
157+ expect ( res . statusCode ) . toBe ( 302 ) ;
158+ expect ( res . headers . location ) . toContain ( 'error=connect_failed' ) ;
159+ } ) ;
160+
161+ it ( 'forged state (random nonce not stored in Redis) redirects with connect_failed' , async ( ) => {
162+ mockRedis . get . mockResolvedValue ( null ) ;
163+ const forgedNonce = 'b' . repeat ( 64 ) ;
164+ const app = await buildApp ( ) ;
165+ const res = await app . inject ( {
166+ method : 'GET' ,
167+ url : `/api/connect/github/callback?code=validcode&state=${ forgedNonce } ` ,
168+ } ) ;
169+ expect ( res . statusCode ) . toBe ( 302 ) ;
170+ expect ( res . headers . location ) . toContain ( 'error=connect_failed' ) ;
171+ } ) ;
172+
173+ it ( 'replay attack: second use of same nonce fails after first succeeds' , async ( ) => {
174+ mockRedis . del . mockResolvedValue ( 1 ) ;
175+ mockPrisma . oAuthToken . upsert . mockResolvedValue ( { } ) ;
176+
177+ global . fetch = vi . fn ( ) . mockResolvedValue ( {
178+ json : async ( ) => ( { access_token : 'gh-token' , scope : 'user:follow' } ) ,
179+ } ) as any ;
180+
181+ // First call succeeds
182+ mockRedis . get . mockResolvedValueOnce ( JSON . stringify ( { userId : USER_ID } ) ) ;
183+ const app = await buildApp ( ) ;
184+ const res1 = await app . inject ( {
185+ method : 'GET' ,
186+ url : `/api/connect/github/callback?code=code1&state=${ VALID_NONCE } ` ,
187+ } ) ;
188+ expect ( res1 . statusCode ) . toBe ( 302 ) ;
189+ expect ( res1 . headers . location ) . toContain ( 'connected=github' ) ;
190+
191+ // Second call: nonce deleted, Redis returns null
192+ mockRedis . get . mockResolvedValueOnce ( null ) ;
193+ const res2 = await app . inject ( {
194+ method : 'GET' ,
195+ url : `/api/connect/github/callback?code=code2&state=${ VALID_NONCE } ` ,
196+ } ) ;
197+ expect ( res2 . statusCode ) . toBe ( 302 ) ;
198+ expect ( res2 . headers . location ) . toContain ( 'error=connect_failed' ) ;
199+ } ) ;
200+
201+ it ( 'corrupt nonce data in Redis redirects with connect_failed' , async ( ) => {
202+ mockRedis . get . mockResolvedValue ( 'not-valid-json{{{' ) ;
203+ mockRedis . del . mockResolvedValue ( 1 ) ;
204+ const app = await buildApp ( ) ;
205+ const res = await app . inject ( {
206+ method : 'GET' ,
207+ url : `/api/connect/github/callback?code=validcode&state=${ VALID_NONCE } ` ,
208+ } ) ;
209+ expect ( res . statusCode ) . toBe ( 302 ) ;
210+ expect ( res . headers . location ) . toContain ( 'error=connect_failed' ) ;
211+ } ) ;
212+
213+ it ( 'GitHub token exchange failure redirects with connect_failed' , async ( ) => {
214+ mockRedis . get . mockResolvedValue ( JSON . stringify ( { userId : USER_ID } ) ) ;
215+ mockRedis . del . mockResolvedValue ( 1 ) ;
216+
217+ global . fetch = vi . fn ( ) . mockResolvedValue ( {
218+ json : async ( ) => ( { error : 'bad_verification_code' } ) ,
219+ } ) as any ;
220+
221+ const app = await buildApp ( ) ;
222+ const res = await app . inject ( {
223+ method : 'GET' ,
224+ url : `/api/connect/github/callback?code=badcode&state=${ VALID_NONCE } ` ,
225+ } ) ;
226+ expect ( res . statusCode ) . toBe ( 302 ) ;
227+ expect ( res . headers . location ) . toContain ( 'error=connect_failed' ) ;
228+ } ) ;
229+ } ) ;
0 commit comments