Skip to content

Commit 5d285d0

Browse files
authored
Merge pull request #1174 from jotel-dev/847-testing-admin-indexer-routes
test(backend): add route-level integration tests for admin indexer endpoints (#847)
2 parents b38f208 + 068bad8 commit 5d285d0

4 files changed

Lines changed: 308 additions & 102 deletions

File tree

.github/workflows/ci.yml

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,6 @@ jobs:
3838
run: npm install @rollup/rollup-linux-x64-gnu --no-save
3939
working-directory: frontend
4040

41-
# Frontend devDeps do not list @vitest/coverage-v8 (mirrors the
42-
# backend workflow), so install it ad-hoc before coverage runs.
43-
- name: Install Vitest Coverage Provider
44-
run: npm install @vitest/coverage-v8@3.2.7 --no-save
45-
working-directory: frontend
46-
4741
- name: Run Frontend Tests
4842
run: npm run test:coverage
4943
working-directory: frontend

.github/workflows/pr-test-gate.yml

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,14 +61,8 @@ jobs:
6161
env:
6262
DATABASE_URL: postgresql://postgres:password@127.0.0.1:5432/flowfi_test
6363

64-
# mirrors the ci.yml backend job: vitest's coverage provider is
65-
# enabled by default in backend/vitest.config.ts, so the @vitest/
66-
# coverage-v8 package must be present before `npm test` runs.
67-
# Also installs the rollup native binding into backend/node_modules
68-
# (where vitest actually resolves it from) rather than the root.
69-
- name: Install Vitest + Native Bindings
64+
- name: Install Native Bindings
7065
run: |
71-
npm install @vitest/coverage-v8@3.2.7 --no-save
7266
npm install @rollup/rollup-linux-x64-gnu --no-save
7367
working-directory: backend
7468

backend/tests/integration/admin-metrics.test.ts

Lines changed: 230 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -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

9389
vi.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

113109
import app from '../../src/app.js';
114110
import { 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+
118134
function setupCounts({
119135
total = 10,
120136
active = 6,
@@ -136,6 +152,7 @@ function setupCounts({
136152
describe('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

Comments
 (0)