@@ -4,6 +4,29 @@ import express from 'express';
44import jwt from 'jsonwebtoken' ;
55import { createTestDb } from '../helpers/db.js' ;
66import { signTestToken , signExpiredToken , TEST_JWT_SECRET } from '../helpers/jwt.js' ;
7+ import { createApp } from '../../src/app.js' ;
8+ import { InMemoryUsageEventsRepository } from '../../src/repositories/usageEventsRepository.js' ;
9+ import { InMemoryVaultRepository } from '../../src/repositories/vaultRepository.js' ;
10+ import type { Developer } from '../../src/db/schema.js' ;
11+ import type { DeveloperRepository } from '../../src/repositories/developerRepository.js' ;
12+ import type { ApiRepository , ApiListFilters } from '../../src/repositories/apiRepository.js' ;
13+
14+ jest . mock ( 'uuid' , ( ) => ( { v4 : ( ) => 'mock-uuid-1234' } ) ) ;
15+
16+ // Mock better-sqlite3 to avoid native binding requirement in test env
17+ jest . mock ( 'better-sqlite3' , ( ) => {
18+ return class MockDatabase {
19+ prepare ( ) { return { get : ( ) => null } ; }
20+ exec ( ) { }
21+ close ( ) { }
22+ } ;
23+ } ) ;
24+
25+ // Mock the userRepository to avoid the Prisma import chain
26+ // (userRepository → lib/prisma → generated/prisma/client which doesn't exist in test env)
27+ jest . mock ( '../../src/repositories/userRepository' , ( ) => ( {
28+ findUsers : jest . fn ( ) . mockResolvedValue ( { users : [ ] , total : 0 } ) ,
29+ } ) ) ;
730
831function buildProtectedApp ( pool : any ) {
932 const app = express ( ) ;
@@ -109,3 +132,244 @@ describe('GET /api/usage - JWT protected', () => {
109132 expect ( res . body . error ) . toBe ( 'No token provided' ) ;
110133 } ) ;
111134} ) ;
135+
136+ // ---------------------------------------------------------------------------
137+ // requireAuth middleware – integration coverage against real createApp routes
138+ // ---------------------------------------------------------------------------
139+
140+ const testDeveloper : Developer = {
141+ id : 7 ,
142+ user_id : 'user-42' ,
143+ name : 'Integration Tester' ,
144+ website : null ,
145+ description : null ,
146+ category : null ,
147+ created_at : new Date ( 0 ) ,
148+ updated_at : new Date ( 0 ) ,
149+ } ;
150+
151+ const stubDeveloperRepository : DeveloperRepository = {
152+ async findByUserId ( userId : string ) {
153+ return userId === testDeveloper . user_id ? testDeveloper : undefined ;
154+ } ,
155+ } ;
156+
157+ class StubApiRepository implements ApiRepository {
158+ async listByDeveloper ( _developerId : number , _filters ?: ApiListFilters ) {
159+ return [ ] ;
160+ }
161+ async findById ( ) {
162+ return null ;
163+ }
164+ async getEndpoints ( ) {
165+ return [ ] ;
166+ }
167+ }
168+
169+ /**
170+ * Build a createApp instance with lightweight in-memory stubs so that
171+ * route handlers can execute without hitting a real database.
172+ */
173+ function buildRealApp ( ) {
174+ const vaultRepo = new InMemoryVaultRepository ( ) ;
175+ return createApp ( {
176+ usageEventsRepository : new InMemoryUsageEventsRepository ( ) ,
177+ vaultRepository : vaultRepo ,
178+ developerRepository : stubDeveloperRepository ,
179+ apiRepository : new StubApiRepository ( ) ,
180+ findDeveloperByUserId : async ( id ) => stubDeveloperRepository . findByUserId ( id ) ,
181+ createApiWithEndpoints : async ( input ) => ( {
182+ id : 1 ,
183+ developer_id : input . developer_id ,
184+ name : input . name ,
185+ description : input . description ?? null ,
186+ base_url : input . base_url ,
187+ logo_url : null ,
188+ category : input . category ?? null ,
189+ status : input . status ?? 'draft' ,
190+ created_at : new Date ( ) ,
191+ updated_at : new Date ( ) ,
192+ endpoints : [ ] ,
193+ } ) ,
194+ } ) ;
195+ }
196+
197+ /** Standard assertion for an unauthenticated response from the errorHandler */
198+ function expectUnauthorized ( res : request . Response ) {
199+ expect ( res . status ) . toBe ( 401 ) ;
200+ expect ( res . body ) . toHaveProperty ( 'error' ) ;
201+ expect ( res . body . error ) . toBe ( 'Unauthorized' ) ;
202+ expect ( res . body . code ) . toBe ( 'UNAUTHORIZED' ) ;
203+ }
204+
205+ // Collect every protected endpoint so we can run the same failure-mode matrix
206+ // against each one without duplicating boilerplate.
207+ const protectedEndpoints : Array < {
208+ method : 'get' | 'post' | 'delete' ;
209+ path : string ;
210+ body ?: Record < string , unknown > ;
211+ } > = [
212+ { method : 'get' , path : '/api/developers/apis' } ,
213+ { method : 'get' , path : '/api/developers/analytics' } ,
214+ { method : 'post' , path : '/api/vault/deposit/prepare' , body : { amount_usdc : '10.00' } } ,
215+ { method : 'get' , path : '/api/vault/balance' } ,
216+ { method : 'delete' , path : '/api/keys/nonexistent-id' } ,
217+ { method : 'post' , path : '/api/developers/apis' , body : { name : 'Test' , base_url : 'https://t.co' , endpoints : [ ] } } ,
218+ ] ;
219+
220+ describe ( 'requireAuth – rejects unauthenticated requests on all protected routes' , ( ) => {
221+ let app : express . Express ;
222+
223+ beforeAll ( ( ) => {
224+ app = buildRealApp ( ) ;
225+ } ) ;
226+
227+ describe . each ( protectedEndpoints ) (
228+ '$method $path' ,
229+ ( { method, path, body } ) => {
230+ it ( 'returns 401 when no auth headers are present' , async ( ) => {
231+ const req = request ( app ) [ method ] ( path ) ;
232+ if ( body ) req . send ( body ) ;
233+ const res = await req ;
234+ expectUnauthorized ( res ) ;
235+ } ) ;
236+
237+ it ( 'returns 401 when Bearer token is empty' , async ( ) => {
238+ const req = request ( app ) [ method ] ( path ) . set ( 'Authorization' , 'Bearer ' ) ;
239+ if ( body ) req . send ( body ) ;
240+ const res = await req ;
241+ expectUnauthorized ( res ) ;
242+ } ) ;
243+
244+ it ( 'returns 401 when Bearer token is whitespace-only' , async ( ) => {
245+ const req = request ( app ) [ method ] ( path ) . set ( 'Authorization' , 'Bearer ' ) ;
246+ if ( body ) req . send ( body ) ;
247+ const res = await req ;
248+ expectUnauthorized ( res ) ;
249+ } ) ;
250+
251+ it ( 'returns 401 with non-Bearer scheme (Basic)' , async ( ) => {
252+ const req = request ( app ) [ method ] ( path ) . set ( 'Authorization' , 'Basic dXNlcjpwYXNz' ) ;
253+ if ( body ) req . send ( body ) ;
254+ const res = await req ;
255+ expectUnauthorized ( res ) ;
256+ } ) ;
257+ } ,
258+ ) ;
259+ } ) ;
260+
261+ describe ( 'requireAuth – accepts valid credentials on protected routes' , ( ) => {
262+ let app : express . Express ;
263+
264+ beforeAll ( ( ) => {
265+ app = buildRealApp ( ) ;
266+ } ) ;
267+
268+ it ( 'authenticates via Bearer token on GET /api/developers/apis' , async ( ) => {
269+ const res = await request ( app )
270+ . get ( '/api/developers/apis' )
271+ . set ( 'Authorization' , 'Bearer user-42' ) ;
272+
273+ // Auth passes; the route itself may return 200 (empty list) or 404 depending on developer lookup
274+ expect ( res . status ) . not . toBe ( 401 ) ;
275+ } ) ;
276+
277+ it ( 'authenticates via x-user-id header on GET /api/developers/apis' , async ( ) => {
278+ const res = await request ( app )
279+ . get ( '/api/developers/apis' )
280+ . set ( 'x-user-id' , 'user-42' ) ;
281+
282+ expect ( res . status ) . not . toBe ( 401 ) ;
283+ } ) ;
284+
285+ it ( 'authenticates via Bearer token on GET /api/developers/analytics' , async ( ) => {
286+ const res = await request ( app )
287+ . get ( '/api/developers/analytics?from=2026-01-01&to=2026-01-31' )
288+ . set ( 'Authorization' , 'Bearer user-42' ) ;
289+
290+ expect ( res . status ) . not . toBe ( 401 ) ;
291+ } ) ;
292+
293+ it ( 'authenticates via x-user-id header on GET /api/developers/analytics' , async ( ) => {
294+ const res = await request ( app )
295+ . get ( '/api/developers/analytics?from=2026-01-01&to=2026-01-31' )
296+ . set ( 'x-user-id' , 'user-42' ) ;
297+
298+ expect ( res . status ) . not . toBe ( 401 ) ;
299+ } ) ;
300+
301+ it ( 'authenticates via Bearer token on POST /api/vault/deposit/prepare' , async ( ) => {
302+ const res = await request ( app )
303+ . post ( '/api/vault/deposit/prepare' )
304+ . set ( 'Authorization' , 'Bearer user-42' )
305+ . send ( { amount_usdc : '10.00' } ) ;
306+
307+ // 404 (no vault) is acceptable — not 401
308+ expect ( res . status ) . not . toBe ( 401 ) ;
309+ } ) ;
310+
311+ it ( 'authenticates via x-user-id header on GET /api/vault/balance' , async ( ) => {
312+ const res = await request ( app )
313+ . get ( '/api/vault/balance' )
314+ . set ( 'x-user-id' , 'user-42' ) ;
315+
316+ // 404 (no vault) is acceptable — not 401
317+ expect ( res . status ) . not . toBe ( 401 ) ;
318+ } ) ;
319+
320+ it ( 'authenticates via Bearer token on DELETE /api/keys/:id' , async ( ) => {
321+ const res = await request ( app )
322+ . delete ( '/api/keys/nonexistent-id' )
323+ . set ( 'Authorization' , 'Bearer user-42' ) ;
324+
325+ // 204 (not_found falls through to 204 in current impl) — not 401
326+ expect ( res . status ) . not . toBe ( 401 ) ;
327+ } ) ;
328+
329+ it ( 'authenticates via x-user-id header on POST /api/developers/apis' , async ( ) => {
330+ const res = await request ( app )
331+ . post ( '/api/developers/apis' )
332+ . set ( 'x-user-id' , 'user-42' )
333+ . send ( { name : 'My API' , base_url : 'https://example.com' , endpoints : [ ] } ) ;
334+
335+ expect ( res . status ) . not . toBe ( 401 ) ;
336+ } ) ;
337+ } ) ;
338+
339+ describe ( 'requireAuth – error body consistency' , ( ) => {
340+ let app : express . Express ;
341+
342+ beforeAll ( ( ) => {
343+ app = buildRealApp ( ) ;
344+ } ) ;
345+
346+ it ( 'returns JSON content-type for 401 responses' , async ( ) => {
347+ const res = await request ( app ) . get ( '/api/developers/apis' ) ;
348+
349+ expect ( res . status ) . toBe ( 401 ) ;
350+ expect ( res . headers [ 'content-type' ] ) . toMatch ( / a p p l i c a t i o n \/ j s o n / ) ;
351+ } ) ;
352+
353+ it ( 'does not leak stack traces or internal details in 401 body' , async ( ) => {
354+ const res = await request ( app ) . get ( '/api/vault/balance' ) ;
355+
356+ expect ( res . status ) . toBe ( 401 ) ;
357+ expect ( res . body ) . not . toHaveProperty ( 'stack' ) ;
358+ expect ( res . body ) . not . toHaveProperty ( 'statusCode' ) ;
359+ // Only expected keys
360+ const keys = Object . keys ( res . body ) ;
361+ expect ( keys ) . toEqual ( expect . arrayContaining ( [ 'error' , 'code' ] ) ) ;
362+ expect ( keys . length ) . toBe ( 2 ) ;
363+ } ) ;
364+
365+ it ( 'produces identical error shape across different protected routes' , async ( ) => {
366+ const res1 = await request ( app ) . get ( '/api/developers/apis' ) ;
367+ const res2 = await request ( app ) . post ( '/api/vault/deposit/prepare' ) . send ( { } ) ;
368+ const res3 = await request ( app ) . delete ( '/api/keys/abc' ) ;
369+
370+ for ( const res of [ res1 , res2 , res3 ] ) {
371+ expect ( res . status ) . toBe ( 401 ) ;
372+ expect ( res . body ) . toEqual ( { error : 'Unauthorized' , code : 'UNAUTHORIZED' } ) ;
373+ }
374+ } ) ;
375+ } ) ;
0 commit comments