1+ import Fastify from 'fastify' ;
2+ import { describe , it , expect , beforeEach , afterEach , vi } from 'vitest' ;
3+
4+ import { authRoutes } from '../routes/auth' ;
5+
6+ import type { JWT } from '@fastify/jwt' ;
7+ import type { PrismaClient } from '@prisma/client' ;
8+ import type { FastifyInstance , FastifyReply , FastifyRequest } from 'fastify' ;
9+ import type { Redis } from 'ioredis' ;
10+
11+
12+ const MOCK_CLIENT_ID = 'mock-github-client-id' ;
13+ const MOCK_GOOGLE_CLIENT_ID = 'mock-google-client-id' ;
14+ const MOCK_BACKEND_URL = 'http://localhost:3000' ;
15+
16+
17+ async function buildApp ( ) : Promise < FastifyInstance > {
18+ const app = Fastify ( { logger : false } ) ;
19+
20+ await app . register ( import ( '@fastify/cookie' ) ) ;
21+
22+ //as not testing this here
23+ app . decorate ( 'authenticate' , async ( _request : FastifyRequest , reply : FastifyReply ) => {
24+ reply . status ( 401 ) . send ( { error : 'Unauthorized' } ) ;
25+ } ) ;
26+
27+ app . decorate ( 'jwt' , {
28+ sign : vi . fn ( ) . mockReturnValue ( 'mock-token' ) ,
29+ decode : vi . fn ( ) ,
30+ verify : vi . fn ( ) ,
31+ } as unknown as JWT ) ;
32+
33+ app . decorate ( 'prisma' , {
34+ user : { findUnique : vi . fn ( ) , create : vi . fn ( ) , update : vi . fn ( ) } ,
35+ userIdentity : { findUnique : vi . fn ( ) , create : vi . fn ( ) } ,
36+ refreshToken : { findUnique : vi . fn ( ) , create : vi . fn ( ) , update : vi . fn ( ) , updateMany : vi . fn ( ) } ,
37+ } as unknown as PrismaClient ) ;
38+
39+ app . decorate ( 'redis' , {
40+ set : vi . fn ( ) ,
41+ get : vi . fn ( ) ,
42+ getdel : vi . fn ( ) ,
43+ } as unknown as Redis ) ;
44+
45+ await app . register ( authRoutes , { prefix : '/auth' } ) ;
46+ await app . ready ( ) ;
47+ return app ;
48+ }
49+
50+ describe ( 'Auth API — OAuth initiation' , ( ) => {
51+ let app : FastifyInstance ;
52+
53+ beforeEach ( async ( ) => {
54+ vi . clearAllMocks ( ) ;
55+ vi . stubEnv ( 'GITHUB_CLIENT_ID' , MOCK_CLIENT_ID ) ;
56+ vi . stubEnv ( 'GOOGLE_CLIENT_ID' , MOCK_GOOGLE_CLIENT_ID ) ;
57+ vi . stubEnv ( 'BACKEND_URL' , MOCK_BACKEND_URL ) ;
58+ vi . stubEnv ( 'NODE_ENV' , 'test' ) ;
59+ app = await buildApp ( ) ; //fresh app instance before and after each instance
60+ } ) ;
61+
62+ afterEach ( async ( ) => {
63+ vi . unstubAllEnvs ( ) ;
64+ await app . close ( ) ; //fresh app instance before and after each instance
65+ } ) ;
66+
67+ // /auth/github
68+ describe ( 'GET /auth/github — OAuth initiation' , ( ) => {
69+ it ( '302 — redirects to GitHub with valid query params' , async ( ) => {
70+ const res = await app . inject ( {
71+ method : 'GET' ,
72+ url : '/auth/github' ,
73+ } ) ;
74+
75+ expect ( res . statusCode ) . toBe ( 302 ) ;
76+ expect ( res . headers . location ) . toContain ( 'github.com/login/oauth/authorize' ) ;
77+ } ) ;
78+
79+ it ( '302 — sets oauth_state cookie on redirect' , async ( ) => {
80+ const res = await app . inject ( {
81+ method : 'GET' ,
82+ url : '/auth/github' ,
83+ } ) ;
84+
85+ expect ( res . statusCode ) . toBe ( 302 ) ;
86+ expect ( res . headers [ 'set-cookie' ] ) . toBeDefined ( ) ;
87+ expect ( res . headers [ 'set-cookie' ] ) . toMatch ( / o a u t h _ s t a t e = / ) ;
88+ } ) ;
89+
90+ it ( '302 — accepts valid state param' , async ( ) => {
91+ const res = await app . inject ( {
92+ method : 'GET' ,
93+ url : '/auth/github?state=some-client-state' ,
94+ } ) ;
95+
96+ expect ( res . statusCode ) . toBe ( 302 ) ;
97+ } ) ;
98+
99+ it ( '302 — accepts valid mobile_redirect_uri' , async ( ) => {
100+ const res = await app . inject ( {
101+ method : 'GET' ,
102+ url : '/auth/github?mobile_redirect_uri=devcard://callback' ,
103+ } ) ;
104+
105+ expect ( res . statusCode ) . toBe ( 302 ) ;
106+ } ) ;
107+
108+ it ( '400 — rejects invalid mobile_redirect_uri' , async ( ) => {
109+ const res = await app . inject ( {
110+ method : 'GET' ,
111+ url : '/auth/github?mobile_redirect_uri=https://evil.com/callback' ,
112+ } ) ;
113+
114+ expect ( res . statusCode ) . toBe ( 400 ) ;
115+ expect ( res . json ( ) ) . toMatchObject ( { error : expect . any ( String ) } ) ;
116+ } ) ;
117+
118+ it ( '400 — rejects mobile_redirect_uri that is not devcard:// scheme' , async ( ) => {
119+ const res = await app . inject ( {
120+ method : 'GET' ,
121+ url : '/auth/github?mobile_redirect_uri=http://localhost/callback' ,
122+ } ) ;
123+
124+ expect ( res . statusCode ) . toBe ( 400 ) ;
125+ } ) ;
126+
127+ it ( '400 — returns 400 when GITHUB_CLIENT_ID is missing' , async ( ) => {
128+ vi . stubEnv ( 'GITHUB_CLIENT_ID' , '' ) ;
129+
130+ const res = await app . inject ( {
131+ method : 'GET' ,
132+ url : '/auth/github' ,
133+ } ) ;
134+
135+ expect ( res . statusCode ) . toBe ( 400 ) ;
136+ } ) ;
137+ } ) ;
138+
139+ // /auth/google
140+ describe ( 'GET /auth/google — OAuth initiation' , ( ) => {
141+ it ( '302 — redirects to Google with valid query params' , async ( ) => {
142+ const res = await app . inject ( {
143+ method : 'GET' ,
144+ url : '/auth/google' ,
145+ } ) ;
146+
147+ expect ( res . statusCode ) . toBe ( 302 ) ;
148+ expect ( res . headers . location ) . toContain ( 'accounts.google.com/o/oauth2/v2/auth' ) ;
149+ } ) ;
150+
151+ it ( '302 — sets oauth_state cookie on redirect' , async ( ) => {
152+ const res = await app . inject ( {
153+ method : 'GET' ,
154+ url : '/auth/google' ,
155+ } ) ;
156+
157+ expect ( res . statusCode ) . toBe ( 302 ) ;
158+ expect ( res . headers [ 'set-cookie' ] ) . toBeDefined ( ) ;
159+ expect ( res . headers [ 'set-cookie' ] ) . toMatch ( / o a u t h _ s t a t e = / ) ;
160+ } ) ;
161+
162+ it ( '302 — accepts valid state param' , async ( ) => {
163+ const res = await app . inject ( {
164+ method : 'GET' ,
165+ url : '/auth/google?state=some-client-state' ,
166+ } ) ;
167+
168+ expect ( res . statusCode ) . toBe ( 302 ) ;
169+ } ) ;
170+
171+ it ( '302 — accepts valid mobile_redirect_uri' , async ( ) => {
172+ const res = await app . inject ( {
173+ method : 'GET' ,
174+ url : '/auth/google?mobile_redirect_uri=devcard://callback' ,
175+ } ) ;
176+
177+ expect ( res . statusCode ) . toBe ( 302 ) ;
178+ } ) ;
179+
180+ it ( '400 — rejects invalid mobile_redirect_uri' , async ( ) => {
181+ const res = await app . inject ( {
182+ method : 'GET' ,
183+ url : '/auth/google?mobile_redirect_uri=https://evil.com/callback' ,
184+ } ) ;
185+
186+ expect ( res . statusCode ) . toBe ( 400 ) ;
187+ expect ( res . json ( ) ) . toMatchObject ( { error : expect . any ( String ) } ) ;
188+ } ) ;
189+
190+ it ( '400 — rejects mobile_redirect_uri that is not devcard:// scheme' , async ( ) => {
191+ const res = await app . inject ( {
192+ method : 'GET' ,
193+ url : '/auth/google?mobile_redirect_uri=http://localhost/callback' ,
194+ } ) ;
195+
196+ expect ( res . statusCode ) . toBe ( 400 ) ;
197+ } ) ;
198+
199+ it ( '400 — returns 400 when GOOGLE_CLIENT_ID is missing' , async ( ) => {
200+ vi . stubEnv ( 'GOOGLE_CLIENT_ID' , '' ) ;
201+
202+ const res = await app . inject ( {
203+ method : 'GET' ,
204+ url : '/auth/google' ,
205+ } ) ;
206+
207+ expect ( res . statusCode ) . toBe ( 400 ) ;
208+ } ) ;
209+ } ) ;
210+ } ) ;
0 commit comments