@@ -83,11 +83,7 @@ vi.mock('../../src/middleware/auth.js', async () => {
8383 const actual = await vi . importActual < typeof import ( '../../src/middleware/auth.js' ) > (
8484 '../../src/middleware/auth.js' ,
8585 ) ;
86- return {
87- ...actual ,
88- requireAdmin : ( _req : unknown , _res : unknown , next : ( ) => void ) => next ( ) ,
89- requireAuth : ( _req : unknown , _res : unknown , next : ( ) => void ) => next ( ) ,
90- } ;
86+ return actual ;
9187} ) ;
9288
9389vi . mock ( '../../src/services/indexerService.js' , ( ) => ( {
@@ -108,13 +104,33 @@ vi.mock('../../src/workers/soroban-event-worker.js', () => ({
108104 SorobanEventWorker : vi . fn ( ) ,
109105} ) ) ;
110106
111- // ─── Import app after mocks are registered ────────── ──────────────────────────
107+ // ─── Import app and auth after mocks are registered ──────────────────────────
112108
113109import app from '../../src/app.js' ;
114110import { sorobanEventWorker } from '../../src/workers/soroban-event-worker.js' ;
111+ import { signJwt } from '../../src/middleware/auth.js' ;
112+ import {
113+ getIndexerStatus ,
114+ resetIndexer ,
115+ replayFromLedger ,
116+ } from '../../src/services/indexerService.js' ;
115117
116118// ─── Helpers ──────────────────────────────────────────────────────────────────
117119
120+ const ADMIN_PUBLIC_KEY = 'GADMIN12345678901234567890123456789012345678901234567890' ;
121+ const NON_ADMIN_PUBLIC_KEY = 'GUSER12345678901234567890123456789012345678901234567890' ;
122+
123+ function createToken ( publicKey : string = ADMIN_PUBLIC_KEY ) : string {
124+ const now = Math . floor ( Date . now ( ) / 1000 ) ;
125+ return signJwt ( {
126+ sub : publicKey ,
127+ iat : now ,
128+ exp : now + 3600 ,
129+ iss : 'flowfi-api' ,
130+ aud : 'flowfi-api' ,
131+ } ) ;
132+ }
133+
118134function setupCounts ( {
119135 total = 10 ,
120136 active = 6 ,
@@ -136,6 +152,7 @@ function setupCounts({
136152describe ( 'GET /v1/admin/metrics' , ( ) => {
137153 beforeEach ( ( ) => {
138154 vi . clearAllMocks ( ) ;
155+ process . env . ADMIN_PUBLIC_KEY = ADMIN_PUBLIC_KEY ;
139156 mocks . cache . get . mockReturnValue ( null ) ;
140157 mocks . prisma . streamEvent . count . mockResolvedValue ( 0 ) ;
141158 mocks . prisma . streamEvent . findMany . mockResolvedValue ( [ ] ) ;
@@ -151,7 +168,9 @@ describe('GET /v1/admin/metrics', () => {
151168 { withdrawnAmount : '0' } ,
152169 ] ) ;
153170
154- const res = await request ( app ) . get ( '/v1/admin/metrics' ) ;
171+ const res = await request ( app )
172+ . get ( '/v1/admin/metrics' )
173+ . set ( 'Authorization' , `Bearer ${ createToken ( ) } ` ) ;
155174
156175 expect ( res . status ) . toBe ( 200 ) ;
157176 expect ( res . body ) . toMatchObject ( {
@@ -173,7 +192,9 @@ describe('GET /v1/admin/metrics', () => {
173192 { withdrawnAmount : '9007199254740993' } ,
174193 ] ) ;
175194
176- const res = await request ( app ) . get ( '/v1/admin/metrics' ) ;
195+ const res = await request ( app )
196+ . get ( '/v1/admin/metrics' )
197+ . set ( 'Authorization' , `Bearer ${ createToken ( ) } ` ) ;
177198
178199 expect ( res . status ) . toBe ( 200 ) ;
179200 expect ( res . body . total_volume_streamed ) . toBe ( '18014398509481986' ) ;
@@ -182,7 +203,9 @@ describe('GET /v1/admin/metrics', () => {
182203 it ( 'caches the response for 60 seconds' , async ( ) => {
183204 setupCounts ( { total : 4 , active : 4 } ) ;
184205
185- const first = await request ( app ) . get ( '/v1/admin/metrics' ) ;
206+ const first = await request ( app )
207+ . get ( '/v1/admin/metrics' )
208+ . set ( 'Authorization' , `Bearer ${ createToken ( ) } ` ) ;
186209 expect ( first . status ) . toBe ( 200 ) ;
187210 expect ( first . headers [ 'x-cache' ] ) . toBe ( 'MISS' ) ;
188211
@@ -214,7 +237,9 @@ describe('GET /v1/admin/metrics', () => {
214237 } ;
215238 mocks . cache . get . mockReturnValueOnce ( cachedPayload ) ;
216239
217- const res = await request ( app ) . get ( '/v1/admin/metrics' ) ;
240+ const res = await request ( app )
241+ . get ( '/v1/admin/metrics' )
242+ . set ( 'Authorization' , `Bearer ${ createToken ( ) } ` ) ;
218243
219244 expect ( res . status ) . toBe ( 200 ) ;
220245 expect ( res . headers [ 'x-cache' ] ) . toBe ( 'HIT' ) ;
@@ -232,7 +257,9 @@ describe('GET /v1/admin/metrics', () => {
232257 degraded : true ,
233258 } ) ;
234259
235- const res = await request ( app ) . get ( '/v1/admin/metrics' ) ;
260+ const res = await request ( app )
261+ . get ( '/v1/admin/metrics' )
262+ . set ( 'Authorization' , `Bearer ${ createToken ( ) } ` ) ;
236263
237264 expect ( res . status ) . toBe ( 200 ) ;
238265 expect ( res . body . indexer ) . toMatchObject ( {
@@ -263,7 +290,9 @@ describe('GET /v1/admin/metrics', () => {
263290 degraded : true ,
264291 } ) ;
265292
266- const res = await request ( app ) . get ( '/v1/admin/metrics' ) ;
293+ const res = await request ( app )
294+ . get ( '/v1/admin/metrics' )
295+ . set ( 'Authorization' , `Bearer ${ createToken ( ) } ` ) ;
267296
268297 expect ( res . status ) . toBe ( 200 ) ;
269298 expect ( res . headers [ 'x-cache' ] ) . toBe ( 'HIT' ) ;
@@ -276,3 +305,192 @@ describe('GET /v1/admin/metrics', () => {
276305 } ) ;
277306 } ) ;
278307} ) ;
308+
309+ describe ( 'GET /v1/admin/indexer/status' , ( ) => {
310+ beforeEach ( ( ) => {
311+ vi . clearAllMocks ( ) ;
312+ process . env . ADMIN_PUBLIC_KEY = ADMIN_PUBLIC_KEY ;
313+ } ) ;
314+
315+ it ( 'enforces requireAdmin (401 without token, 403 with non-admin token)' , async ( ) => {
316+ const noTokenRes = await request ( app ) . get ( '/v1/admin/indexer/status' ) ;
317+ expect ( noTokenRes . status ) . toBe ( 401 ) ;
318+
319+ const nonAdminRes = await request ( app )
320+ . get ( '/v1/admin/indexer/status' )
321+ . set ( 'Authorization' , `Bearer ${ createToken ( NON_ADMIN_PUBLIC_KEY ) } ` ) ;
322+ expect ( nonAdminRes . status ) . toBe ( 403 ) ;
323+ expect ( nonAdminRes . body . error ) . toBe ( 'Forbidden' ) ;
324+ } ) ;
325+
326+ it ( 'returns status 200 with indexer status data for admin' , async ( ) => {
327+ const mockStatus = {
328+ lastLedger : 12345 ,
329+ lastCursor : 'cursor_abc' ,
330+ updatedAt : '2026-08-08T00:00:00.000Z' ,
331+ lagSeconds : 12 ,
332+ } ;
333+ vi . mocked ( getIndexerStatus ) . mockResolvedValueOnce ( mockStatus as any ) ;
334+
335+ const res = await request ( app )
336+ . get ( '/v1/admin/indexer/status' )
337+ . set ( 'Authorization' , `Bearer ${ createToken ( ) } ` ) ;
338+
339+ expect ( res . status ) . toBe ( 200 ) ;
340+ expect ( res . body ) . toEqual ( mockStatus ) ;
341+ expect ( getIndexerStatus ) . toHaveBeenCalledTimes ( 1 ) ;
342+ } ) ;
343+
344+ it ( 'returns status 500 if getIndexerStatus throws' , async ( ) => {
345+ vi . mocked ( getIndexerStatus ) . mockRejectedValueOnce ( new Error ( 'DB failure' ) ) ;
346+
347+ const res = await request ( app )
348+ . get ( '/v1/admin/indexer/status' )
349+ . set ( 'Authorization' , `Bearer ${ createToken ( ) } ` ) ;
350+
351+ expect ( res . status ) . toBe ( 500 ) ;
352+ expect ( res . body ) . toEqual ( { error : 'Failed to fetch indexer status' } ) ;
353+ } ) ;
354+ } ) ;
355+
356+ describe ( 'POST /v1/admin/indexer/reset' , ( ) => {
357+ beforeEach ( ( ) => {
358+ vi . clearAllMocks ( ) ;
359+ process . env . ADMIN_PUBLIC_KEY = ADMIN_PUBLIC_KEY ;
360+ vi . mocked ( resetIndexer ) . mockResolvedValue ( undefined ) ;
361+ } ) ;
362+
363+ it ( 'enforces requireAdmin (401 without token, 403 with non-admin token)' , async ( ) => {
364+ const noTokenRes = await request ( app )
365+ . post ( '/v1/admin/indexer/reset' )
366+ . send ( { ledger : 100 } ) ;
367+ expect ( noTokenRes . status ) . toBe ( 401 ) ;
368+
369+ const nonAdminRes = await request ( app )
370+ . post ( '/v1/admin/indexer/reset' )
371+ . set ( 'Authorization' , `Bearer ${ createToken ( NON_ADMIN_PUBLIC_KEY ) } ` )
372+ . send ( { ledger : 100 } ) ;
373+ expect ( nonAdminRes . status ) . toBe ( 403 ) ;
374+ } ) ;
375+
376+ it ( 'returns 400 when ledger is missing, negative, or non-integer' , async ( ) => {
377+ const cases = [
378+ { } ,
379+ { ledger : - 1 } ,
380+ { ledger : - 100 } ,
381+ { ledger : 12.34 } ,
382+ { ledger : 'not-a-number' } ,
383+ ] ;
384+
385+ for ( const body of cases ) {
386+ const res = await request ( app )
387+ . post ( '/v1/admin/indexer/reset' )
388+ . set ( 'Authorization' , `Bearer ${ createToken ( ) } ` )
389+ . send ( body ) ;
390+
391+ expect ( res . status ) . toBe ( 400 ) ;
392+ expect ( res . body ) . toEqual ( { error : 'ledger must be a non-negative integer' } ) ;
393+ }
394+
395+ expect ( resetIndexer ) . not . toHaveBeenCalled ( ) ;
396+ } ) ;
397+
398+ it ( 'returns 200 and calls resetIndexer with parsed ledger for valid requests' , async ( ) => {
399+ const validCases = [
400+ { body : { ledger : 500 } , expected : 500 } ,
401+ { body : { ledger : 0 } , expected : 0 } ,
402+ { body : { ledger : '123' } , expected : 123 } ,
403+ ] ;
404+
405+ for ( const { body, expected } of validCases ) {
406+ const res = await request ( app )
407+ . post ( '/v1/admin/indexer/reset' )
408+ . set ( 'Authorization' , `Bearer ${ createToken ( ) } ` )
409+ . send ( body ) ;
410+
411+ expect ( res . status ) . toBe ( 200 ) ;
412+ expect ( res . body ) . toEqual ( { ok : true , lastLedger : expected } ) ;
413+ expect ( resetIndexer ) . toHaveBeenCalledWith ( expected ) ;
414+ }
415+ } ) ;
416+
417+ it ( 'returns 500 when resetIndexer throws an error' , async ( ) => {
418+ vi . mocked ( resetIndexer ) . mockRejectedValueOnce ( new Error ( 'Reset operation failed' ) ) ;
419+
420+ const res = await request ( app )
421+ . post ( '/v1/admin/indexer/reset' )
422+ . set ( 'Authorization' , `Bearer ${ createToken ( ) } ` )
423+ . send ( { ledger : 100 } ) ;
424+
425+ expect ( res . status ) . toBe ( 500 ) ;
426+ expect ( res . body ) . toEqual ( { error : 'Reset failed' } ) ;
427+ } ) ;
428+ } ) ;
429+
430+ describe ( 'POST /v1/admin/indexer/replay' , ( ) => {
431+ beforeEach ( ( ) => {
432+ vi . clearAllMocks ( ) ;
433+ process . env . ADMIN_PUBLIC_KEY = ADMIN_PUBLIC_KEY ;
434+ vi . mocked ( replayFromLedger ) . mockResolvedValue ( undefined as any ) ;
435+ } ) ;
436+
437+ it ( 'enforces requireAdmin (401 without token, 403 with non-admin token)' , async ( ) => {
438+ const noTokenRes = await request ( app ) . post ( '/v1/admin/indexer/replay?from_ledger=100' ) ;
439+ expect ( noTokenRes . status ) . toBe ( 401 ) ;
440+
441+ const nonAdminRes = await request ( app )
442+ . post ( '/v1/admin/indexer/replay?from_ledger=100' )
443+ . set ( 'Authorization' , `Bearer ${ createToken ( NON_ADMIN_PUBLIC_KEY ) } ` ) ;
444+ expect ( nonAdminRes . status ) . toBe ( 403 ) ;
445+ } ) ;
446+
447+ it ( 'returns 400 when from_ledger query parameter is missing, negative, or non-integer' , async ( ) => {
448+ const queryUrls = [
449+ '/v1/admin/indexer/replay' ,
450+ '/v1/admin/indexer/replay?from_ledger=-1' ,
451+ '/v1/admin/indexer/replay?from_ledger=-50' ,
452+ '/v1/admin/indexer/replay?from_ledger=3.14' ,
453+ '/v1/admin/indexer/replay?from_ledger=invalid' ,
454+ ] ;
455+
456+ for ( const url of queryUrls ) {
457+ const res = await request ( app )
458+ . post ( url )
459+ . set ( 'Authorization' , `Bearer ${ createToken ( ) } ` ) ;
460+
461+ expect ( res . status ) . toBe ( 400 ) ;
462+ expect ( res . body ) . toEqual ( { error : 'from_ledger must be a non-negative integer' } ) ;
463+ }
464+
465+ expect ( replayFromLedger ) . not . toHaveBeenCalled ( ) ;
466+ } ) ;
467+
468+ it ( 'returns 202 and calls replayFromLedger with parsed from_ledger for valid requests' , async ( ) => {
469+ const validCases = [
470+ { url : '/v1/admin/indexer/replay?from_ledger=200' , expected : 200 } ,
471+ { url : '/v1/admin/indexer/replay?from_ledger=0' , expected : 0 } ,
472+ ] ;
473+
474+ for ( const { url, expected } of validCases ) {
475+ const res = await request ( app )
476+ . post ( url )
477+ . set ( 'Authorization' , `Bearer ${ createToken ( ) } ` ) ;
478+
479+ expect ( res . status ) . toBe ( 202 ) ;
480+ expect ( res . body ) . toMatchObject ( { ok : true , replayingFrom : expected } ) ;
481+ expect ( replayFromLedger ) . toHaveBeenCalledWith ( expected ) ;
482+ }
483+ } ) ;
484+
485+ it ( 'returns 500 when replayFromLedger throws an error' , async ( ) => {
486+ vi . mocked ( replayFromLedger ) . mockRejectedValueOnce ( new Error ( 'Replay operation failed' ) ) ;
487+
488+ const res = await request ( app )
489+ . post ( '/v1/admin/indexer/replay?from_ledger=200' )
490+ . set ( 'Authorization' , `Bearer ${ createToken ( ) } ` ) ;
491+
492+ expect ( res . status ) . toBe ( 500 ) ;
493+ expect ( res . body ) . toEqual ( { error : 'Replay failed' } ) ;
494+ } ) ;
495+ } ) ;
496+
0 commit comments