From e7ed502835a09999e460b582a0dfba4e92c2f7c1 Mon Sep 17 00:00:00 2001 From: adesuwa-tech Date: Mon, 27 Jul 2026 04:38:58 +0000 Subject: [PATCH 1/3] feat: add dependency probes to /api/indexer/health (Closes #488) --- README.md | 2 +- src/routes/indexer/health.ts | 252 +++++++++++++++++--- tests/indexerHealth.test.ts | 401 ++++++++++++++++++++++++++++++-- tests/indexerHealthEtag.test.ts | 166 ++++++------- 4 files changed, 688 insertions(+), 133 deletions(-) diff --git a/README.md b/README.md index d9f43571..0dce4cc2 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Once running: | `GET /health` | None | Liveness check — returns `{ "status": "ok" }` immediately. Use this to verify the process is up. | | `GET /healthz/dependencies` | None | Shallow dependency probe — Postgres, Soroban RPC, Horizon, webhook queue (Redis). Cached for 5 s. Returns 200/207/503. | | `GET /api/health/ready` | None | **Deep readiness check** — runs four parallel probes with 1-second timeouts each. Returns 200 when ready, 503 when unready. | -| `GET /api/indexer/health` | None | Indexer liveness — compares the persisted cursor against the chain tip. Returns `"ok"` / `"degraded"` / `"down"` in `data.status`. Supports [ETag / conditional GET](#etag--conditional-get-caching). | +| `GET /api/indexer/health` | None | Indexer health — probes external dependencies (Postgres + Soroban RPC) and compares the persisted cursor against the chain tip. Returns `"ok"` / `"degraded"` / `"down"` with dependency statuses in `dependencies` and lag data in `data`. Always HTTP 200. Supports [ETag / conditional GET](#etag--conditional-get-caching). | ### `GET /api/health/ready` response diff --git a/src/routes/indexer/health.ts b/src/routes/indexer/health.ts index 6a347c97..711f2de9 100644 --- a/src/routes/indexer/health.ts +++ b/src/routes/indexer/health.ts @@ -2,31 +2,148 @@ * Indexer health router. * * GET /api/indexer/health - * Reports the indexer's liveness by comparing the persisted cursor (the last - * ledger the indexer has processed) against the current chain tip. The - * difference ("lag") is the primary signal that the indexer has fallen behind. * - * Response status: - * - "ok" — lag is within INDEXER_HEALTH_MAX_LAG ledgers - * - "degraded" — lag exceeds the threshold (indexer is behind) - * - "down" — the chain tip could not be reached (RPC error) + * Health probe for the indexer subsystem. Reports: + * 1. External dependency status (database + Soroban RPC) + * 2. Indexer liveness (cursor lag against the chain tip) * - * The endpoint always returns HTTP 200 with a status field so that uptime - * probes can scrape it without tripping on non-2xx responses; orchestrators - * should alert on the `status` field instead. + * Dependencies + * ──────────── + * • database — Postgres (SELECT 1), stores cursor & events + * • sorobanRpc — Soroban RPC (getLatestLedger), provides chain tip * - * Emits a strong `ETag` (see `../../middleware/etag`) derived from the - * response body and honors `If-None-Match` with a `304 Not Modified` when - * the caller already has the current state. This is a meaningful bandwidth - * saver here because monitoring/orchestrator probes poll this endpoint far - * more often than the underlying cursor/chain-tip actually change. + * Response shape + * ────────────── + * { + * "status": "ok" | "degraded" | "down", + * "correlationId": "", + * "checkedAt": "", + * "dependencies": { + * "database": { "status": "ok"|"down", "latencyMs": , "error?": "…" }, + * "sorobanRpc": { "status": "ok"|"down", "latencyMs": , "error?": "…" } + * }, + * "data": { + * "status": "ok" | "degraded" | "down", + * "cursor": , + * "chainTip": | null, + * "lag": | null, + * "maxLag": + * } + * } + * + * Response codes + * ────────────── + * The endpoint always returns HTTP 200 so that uptime probes can scrape it + * without tripping on non-2xx responses. Orchestrators should alert on the + * `status` field instead. + * + * top-level status: + * - "ok" — all deps healthy + lag within threshold + * - "degraded" — lag exceeds threshold (indexer is behind) + * - "down" — a dependency is unavailable or chain tip unreachable + * + * ETag + * ──── + * Emits a strong `ETag` (see `../../middleware/etag`) derived from the + * response body and honors `If-None-Match` with a `304 Not Modified`. + * + * Injectable dependencies + * ─────────────────────── + * All external I/O is encapsulated in the `IndexerHealthRouterDeps` callbacks + * so tests can substitute fully-controlled stubs. + * + * Security + * ──────── + * No authentication required — the response contains no sensitive data. */ import { Router } from "express"; -import { indexerService } from "../../services/indexerService"; +import { randomUUID } from "crypto"; +import { rpc } from "@stellar/stellar-sdk"; +import { pool } from "../../db/client"; +import { env } from "../../config/env"; import { logger } from "../../config/logger"; import { getRequestId } from "../../lib/requestContext"; import { conditionalGet } from "../../middleware/etag"; +import { indexerService } from "../../services/indexerService"; + +// ─── Types ─────────────────────────────────────────────────────────────── + +export type ProbeStatus = "ok" | "down"; + +export interface ProbeResult { + status: ProbeStatus; + latencyMs: number; + error?: string; +} + +export interface IndexerDependencyHealth { + database: ProbeResult; + sorobanRpc: ProbeResult; +} + +export type IndexerStatus = "ok" | "degraded" | "down"; + +export interface IndexerLagData { + status: IndexerStatus; + cursor: number; + chainTip: number | null; + lag: number | null; + maxLag: number; +} + +export interface IndexerHealthResponse { + status: IndexerStatus; + correlationId: string; + checkedAt: string; + dependencies: IndexerDependencyHealth; + data: IndexerLagData; +} + +// ── Injectable dependency interface ────────────────────────────────────── + +export type ProbeDatabaseFn = () => Promise; +export type ProbeSorobanRpcFn = () => Promise; + +export interface IndexerHealthRouterDeps { + probeDatabase?: ProbeDatabaseFn; + probeSorobanRpc?: ProbeSorobanRpcFn; +} + +// ─── Default probes ────────────────────────────────────────────────────── + +async function defaultProbeDatabase(): Promise { + const start = Date.now(); + try { + await pool.query("SELECT 1"); + return { status: "ok", latencyMs: Date.now() - start }; + } catch { + return { + status: "down", + latencyMs: Date.now() - start, + error: "Database unavailable", + }; + } +} + +async function defaultProbeSorobanRpc(): Promise { + const start = Date.now(); + try { + const server = new rpc.Server(env.SOROBAN_RPC_URL, { + allowHttp: env.SOROBAN_RPC_URL.startsWith("http://"), + }); + await server.getLatestLedger(); + return { status: "ok", latencyMs: Date.now() - start }; + } catch { + return { + status: "down", + latencyMs: Date.now() - start, + error: "Soroban RPC unavailable", + }; + } +} + +// ─── Lag helpers ───────────────────────────────────────────────────────── /** Maximum acceptable cursor lag (in ledgers) before the indexer is "degraded". */ const DEFAULT_MAX_LAG = 50; @@ -37,15 +154,53 @@ function resolveMaxLag(): number { return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_MAX_LAG; } -export function createIndexerHealthRouter(): Router { +function computeLagStatus(cursor: number, chainTip: number | null, maxLag: number): IndexerStatus { + if (chainTip === null) return "down"; + const lag = Math.max(0, chainTip - cursor); + return lag > maxLag ? "degraded" : "ok"; +} + +function computeOverallStatus( + depStatus: IndexerDependencyHealth, + lagStatus: IndexerStatus, +): IndexerStatus { + if (depStatus.database.status === "down" || depStatus.sorobanRpc.status === "down") return "down"; + if (lagStatus === "down") return "down"; + if (lagStatus === "degraded") return "degraded"; + return "ok"; +} + +// ─── Router factory ────────────────────────────────────────────────────── + +/** + * Creates the /api/indexer/health router with injected dependencies. + * + * @param deps.probeDatabase - Override the database probe (tests only). + * @param deps.probeSorobanRpc - Override the Soroban RPC probe (tests only). + */ +export function createIndexerHealthRouter(deps: IndexerHealthRouterDeps = {}): Router { + const probeDb: ProbeDatabaseFn = deps.probeDatabase ?? defaultProbeDatabase; + const probeRpc: ProbeSorobanRpcFn = deps.probeSorobanRpc ?? defaultProbeSorobanRpc; const router = Router(); router.get("/health", async (req, res, next) => { const reqId = getRequestId(); + const correlationId = + ((req.headers["x-correlation-id"] as string | undefined) ?? "").trim() || + reqId || + randomUUID(); const maxLag = resolveMaxLag(); + const requestStart = Date.now(); try { - const cursor = await indexerService.getCursor(); + // Run dependency probes and lag check in parallel + const [database, sorobanRpc, cursor] = await Promise.all([ + probeDb(), + probeRpc(), + indexerService.getCursor(), + ]); + + const dependencies: IndexerDependencyHealth = { database, sorobanRpc }; let chainTip: number | null = null; try { @@ -56,30 +211,53 @@ export function createIndexerHealthRouter(): Router { logger.warn({ reqId, err }, "indexer_health_chain_tip_unavailable"); } - if (chainTip === null) { - const payload = { - data: { - status: "down", - cursor, - chainTip: null, - lag: null, - maxLag, - }, - }; - if (conditionalGet(payload, req, res)) return; - return res.status(200).json(payload); - } + const lag = chainTip !== null ? Math.max(0, chainTip - cursor) : null; + const lagStatus = computeLagStatus(cursor, chainTip, maxLag); + const overallStatus = computeOverallStatus(dependencies, lagStatus); + + const lagData: IndexerLagData = { + status: lagStatus, + cursor, + chainTip, + lag, + maxLag, + }; + + logger.info( + { + correlationId, + reqId, + status: overallStatus, + lagStatus, + cursor, + chainTip, + lag, + maxLag, + database: database.status, + sorobanRpc: sorobanRpc.status, + elapsedMs: Date.now() - requestStart, + }, + "indexer_health_checked", + ); - const lag = Math.max(0, chainTip - cursor); - const status = lag > maxLag ? "degraded" : "ok"; + // ETag is derived only from the deterministic parts of the payload + // (dependencies + lag data). Transient metadata like correlationId + // and checkedAt are excluded so the ETag is stable across requests + // with unchanged dependency/lag state. + const etagPayload = { dependencies, data: lagData }; - logger.info({ reqId, cursor, chainTip, lag, status }, "indexer_health_checked"); + const payload: IndexerHealthResponse = { + status: overallStatus, + correlationId, + checkedAt: new Date().toISOString(), + dependencies, + data: lagData, + }; - const payload = { data: { status, cursor, chainTip, lag, maxLag } }; - if (conditionalGet(payload, req, res)) return; + if (conditionalGet(etagPayload, req, res)) return; return res.status(200).json(payload); } catch (err) { - logger.error({ reqId, err }, "indexer_health_failed"); + logger.error({ reqId, correlationId, err }, "indexer_health_failed"); return next(err); } }); diff --git a/tests/indexerHealth.test.ts b/tests/indexerHealth.test.ts index 4519058c..4adb146a 100644 --- a/tests/indexerHealth.test.ts +++ b/tests/indexerHealth.test.ts @@ -7,26 +7,85 @@ process.env.PREDICTIFY_CONTRACT_ID = "CABCDEF"; process.env.ADMIN_ALLOWLIST = "G-ADMIN-ADDRESS-1,G-ADMIN-ADDRESS-2"; process.env.INDEXER_HEALTH_MAX_LAG = "50"; +// ── Module mocks ───────────────────────────────────────────────────────────── + +jest.mock("../src/services/indexerService", () => ({ + indexerService: { + getCursor: jest.fn(), + getChainTip: jest.fn(), + }, +})); + +// ── Imports ─────────────────────────────────────────────────────────────────── + import request from "supertest"; -import { createApp } from "../src/index"; +import express from "express"; +import { createIndexerHealthRouter } from "../src/routes/indexer/health"; import { indexerService } from "../src/services/indexerService"; +import { errorHandler } from "../src/middleware/errorHandler"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +import type { + ProbeResult, + IndexerDependencyHealth, +} from "../src/routes/indexer/health"; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const ALL_OK: IndexerDependencyHealth = { + database: { status: "ok", latencyMs: 2 }, + sorobanRpc: { status: "ok", latencyMs: 5 }, +}; + +const DB_DOWN: IndexerDependencyHealth = { + database: { status: "down", latencyMs: 100, error: "Database unavailable" }, + sorobanRpc: { status: "ok", latencyMs: 5 }, +}; + +const RPC_DOWN: IndexerDependencyHealth = { + database: { status: "ok", latencyMs: 2 }, + sorobanRpc: { status: "down", latencyMs: 200, error: "Soroban RPC unavailable" }, +}; + +const BOTH_DOWN: IndexerDependencyHealth = { + database: { status: "down", latencyMs: 100, error: "Database unavailable" }, + sorobanRpc: { status: "down", latencyMs: 200, error: "Soroban RPC unavailable" }, +}; + +// ── App factory ─────────────────────────────────────────────────────────────── + +function makeApp(deps: { + probeDatabase?: () => Promise; + probeSorobanRpc?: () => Promise; +} = {}): express.Express { + const app = express(); + app.use("/api/indexer", createIndexerHealthRouter(deps)); + app.use(errorHandler); + return app; +} + +const URL = "/api/indexer/health"; -describe("GET /api/indexer/health", () => { - let getCursor: jest.SpyInstance; - let getChainTip: jest.SpyInstance; +// ═════════════════════════════════════════════════════════════════════════════ +// Lag-based status tests +// ═════════════════════════════════════════════════════════════════════════════ +describe("GET /api/indexer/health — lag-based status", () => { beforeEach(() => { - getCursor = jest.spyOn(indexerService, "getCursor"); - getChainTip = jest.spyOn(indexerService, "getChainTip"); + jest.clearAllMocks(); }); - afterEach(() => jest.restoreAllMocks()); - it("reports ok when cursor lag is within the threshold", async () => { - getCursor.mockResolvedValue(1000); - getChainTip.mockResolvedValue(1010); + (indexerService.getCursor as jest.Mock).mockResolvedValue(1000); + (indexerService.getChainTip as jest.Mock).mockResolvedValue(1010); - const res = await request(createApp()).get("/api/indexer/health"); + const res = await request( + makeApp({ + probeDatabase: () => Promise.resolve(ALL_OK.database), + probeSorobanRpc: () => Promise.resolve(ALL_OK.sorobanRpc), + }), + ).get(URL); expect(res.status).toBe(200); expect(res.body.data).toMatchObject({ @@ -38,10 +97,15 @@ describe("GET /api/indexer/health", () => { }); it("reports degraded when cursor lag exceeds the threshold", async () => { - getCursor.mockResolvedValue(1000); - getChainTip.mockResolvedValue(2000); + (indexerService.getCursor as jest.Mock).mockResolvedValue(1000); + (indexerService.getChainTip as jest.Mock).mockResolvedValue(2000); - const res = await request(createApp()).get("/api/indexer/health"); + const res = await request( + makeApp({ + probeDatabase: () => Promise.resolve(ALL_OK.database), + probeSorobanRpc: () => Promise.resolve(ALL_OK.sorobanRpc), + }), + ).get(URL); expect(res.status).toBe(200); expect(res.body.data.status).toBe("degraded"); @@ -49,10 +113,15 @@ describe("GET /api/indexer/health", () => { }); it("reports down when the chain tip is unreachable", async () => { - getCursor.mockResolvedValue(1000); - getChainTip.mockRejectedValue(new Error("rpc unavailable")); + (indexerService.getCursor as jest.Mock).mockResolvedValue(1000); + (indexerService.getChainTip as jest.Mock).mockRejectedValue(new Error("rpc unavailable")); - const res = await request(createApp()).get("/api/indexer/health"); + const res = await request( + makeApp({ + probeDatabase: () => Promise.resolve(ALL_OK.database), + probeSorobanRpc: () => Promise.resolve(ALL_OK.sorobanRpc), + }), + ).get(URL); expect(res.status).toBe(200); expect(res.body.data.status).toBe("down"); @@ -60,3 +129,301 @@ describe("GET /api/indexer/health", () => { expect(res.body.data.lag).toBeNull(); }); }); + +// ═════════════════════════════════════════════════════════════════════════════ +// Dependency probe tests +// ═════════════════════════════════════════════════════════════════════════════ + +describe("GET /api/indexer/health — dependency probes", () => { + beforeEach(() => { + jest.clearAllMocks(); + (indexerService.getCursor as jest.Mock).mockResolvedValue(1000); + (indexerService.getChainTip as jest.Mock).mockResolvedValue(1010); + }); + + it("returns dependencies object with database and sorobanRpc", async () => { + const res = await request( + makeApp({ + probeDatabase: () => Promise.resolve(ALL_OK.database), + probeSorobanRpc: () => Promise.resolve(ALL_OK.sorobanRpc), + }), + ).get(URL); + + expect(res.body.dependencies).toBeDefined(); + expect(res.body.dependencies.database).toBeDefined(); + expect(res.body.dependencies.sorobanRpc).toBeDefined(); + }); + + it("reports database as ok when probe succeeds", async () => { + const res = await request( + makeApp({ + probeDatabase: () => Promise.resolve(ALL_OK.database), + probeSorobanRpc: () => Promise.resolve(ALL_OK.sorobanRpc), + }), + ).get(URL); + + expect(res.body.dependencies.database.status).toBe("ok"); + expect(res.body.dependencies.database.latencyMs).toBe(2); + }); + + it("reports database as down when probe fails", async () => { + const res = await request( + makeApp({ + probeDatabase: () => Promise.resolve(DB_DOWN.database), + probeSorobanRpc: () => Promise.resolve(ALL_OK.sorobanRpc), + }), + ).get(URL); + + expect(res.body.dependencies.database.status).toBe("down"); + expect(res.body.dependencies.database.error).toBe("Database unavailable"); + }); + + it("reports sorobanRpc as ok when probe succeeds", async () => { + const res = await request( + makeApp({ + probeDatabase: () => Promise.resolve(ALL_OK.database), + probeSorobanRpc: () => Promise.resolve(ALL_OK.sorobanRpc), + }), + ).get(URL); + + expect(res.body.dependencies.sorobanRpc.status).toBe("ok"); + expect(res.body.dependencies.sorobanRpc.latencyMs).toBe(5); + }); + + it("reports sorobanRpc as down when probe fails", async () => { + const res = await request( + makeApp({ + probeDatabase: () => Promise.resolve(ALL_OK.database), + probeSorobanRpc: () => Promise.resolve(RPC_DOWN.sorobanRpc), + }), + ).get(URL); + + expect(res.body.dependencies.sorobanRpc.status).toBe("down"); + expect(res.body.dependencies.sorobanRpc.error).toBe("Soroban RPC unavailable"); + }); + + it("includes top-level status, correlationId, and checkedAt fields", async () => { + const res = await request( + makeApp({ + probeDatabase: () => Promise.resolve(ALL_OK.database), + probeSorobanRpc: () => Promise.resolve(ALL_OK.sorobanRpc), + }), + ).get(URL); + + expect(res.body.status).toBeDefined(); + expect(res.body.correlationId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + ); + expect(res.body.checkedAt).toBeDefined(); + expect(() => new Date(res.body.checkedAt)).not.toThrow(); + }); + + it("each dependency entry contains status and latencyMs", async () => { + const res = await request( + makeApp({ + probeDatabase: () => Promise.resolve(ALL_OK.database), + probeSorobanRpc: () => Promise.resolve(ALL_OK.sorobanRpc), + }), + ).get(URL); + + for (const key of ["database", "sorobanRpc"]) { + expect(res.body.dependencies[key]).toHaveProperty("status"); + expect(res.body.dependencies[key]).toHaveProperty("latencyMs"); + } + }); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// Overall status computation +// ═════════════════════════════════════════════════════════════════════════════ + +describe("GET /api/indexer/health — overall status", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("is 'ok' when deps are healthy and lag is within threshold", async () => { + (indexerService.getCursor as jest.Mock).mockResolvedValue(1000); + (indexerService.getChainTip as jest.Mock).mockResolvedValue(1010); + + const res = await request( + makeApp({ + probeDatabase: () => Promise.resolve(ALL_OK.database), + probeSorobanRpc: () => Promise.resolve(ALL_OK.sorobanRpc), + }), + ).get(URL); + + expect(res.body.status).toBe("ok"); + }); + + it("is 'degraded' when lag exceeds threshold but deps are ok", async () => { + (indexerService.getCursor as jest.Mock).mockResolvedValue(1000); + (indexerService.getChainTip as jest.Mock).mockResolvedValue(2000); + + const res = await request( + makeApp({ + probeDatabase: () => Promise.resolve(ALL_OK.database), + probeSorobanRpc: () => Promise.resolve(ALL_OK.sorobanRpc), + }), + ).get(URL); + + expect(res.body.status).toBe("degraded"); + expect(res.body.dependencies.database.status).toBe("ok"); + expect(res.body.dependencies.sorobanRpc.status).toBe("ok"); + }); + + it("is 'down' when database is down", async () => { + (indexerService.getCursor as jest.Mock).mockResolvedValue(1000); + (indexerService.getChainTip as jest.Mock).mockResolvedValue(1010); + + const res = await request( + makeApp({ + probeDatabase: () => Promise.resolve(DB_DOWN.database), + probeSorobanRpc: () => Promise.resolve(ALL_OK.sorobanRpc), + }), + ).get(URL); + + expect(res.body.status).toBe("down"); + }); + + it("is 'down' when sorobanRpc is down", async () => { + (indexerService.getCursor as jest.Mock).mockResolvedValue(1000); + (indexerService.getChainTip as jest.Mock).mockResolvedValue(1010); + + const res = await request( + makeApp({ + probeDatabase: () => Promise.resolve(ALL_OK.database), + probeSorobanRpc: () => Promise.resolve(RPC_DOWN.sorobanRpc), + }), + ).get(URL); + + expect(res.body.status).toBe("down"); + }); + + it("is 'down' when both deps are down", async () => { + (indexerService.getCursor as jest.Mock).mockResolvedValue(1000); + (indexerService.getChainTip as jest.Mock).mockResolvedValue(1010); + + const res = await request( + makeApp({ + probeDatabase: () => Promise.resolve(BOTH_DOWN.database), + probeSorobanRpc: () => Promise.resolve(BOTH_DOWN.sorobanRpc), + }), + ).get(URL); + + expect(res.body.status).toBe("down"); + }); + + it("is 'down' when chain tip is unreachable even if deps are ok", async () => { + (indexerService.getCursor as jest.Mock).mockResolvedValue(1000); + (indexerService.getChainTip as jest.Mock).mockRejectedValue(new Error("rpc unavailable")); + + const res = await request( + makeApp({ + probeDatabase: () => Promise.resolve(ALL_OK.database), + probeSorobanRpc: () => Promise.resolve(ALL_OK.sorobanRpc), + }), + ).get(URL); + + expect(res.body.status).toBe("down"); + }); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// Other behaviours +// ═════════════════════════════════════════════════════════════════════════════ + +describe("GET /api/indexer/health — other behaviours", () => { + beforeEach(() => { + jest.clearAllMocks(); + (indexerService.getCursor as jest.Mock).mockResolvedValue(1000); + (indexerService.getChainTip as jest.Mock).mockResolvedValue(1010); + }); + + it("does not require authentication", async () => { + const res = await request( + makeApp({ + probeDatabase: () => Promise.resolve(ALL_OK.database), + probeSorobanRpc: () => Promise.resolve(ALL_OK.sorobanRpc), + }), + ).get(URL); + + expect(res.status).toBe(200); + }); + + it("echoes x-correlation-id header when provided", async () => { + const id = "indexer-health-trace-123"; + const res = await request( + makeApp({ + probeDatabase: () => Promise.resolve(ALL_OK.database), + probeSorobanRpc: () => Promise.resolve(ALL_OK.sorobanRpc), + }), + ) + .get(URL) + .set("x-correlation-id", id); + + expect(res.body.correlationId).toBe(id); + }); + + it("runs probes and indexer service in parallel", async () => { + const order: string[] = []; + const probeDatabase = jest.fn().mockImplementation(async () => { + await new Promise((r) => setTimeout(r, 5)); + order.push("database"); + return ALL_OK.database; + }); + const probeSorobanRpc = jest.fn().mockImplementation(async () => { + await new Promise((r) => setTimeout(r, 5)); + order.push("sorobanRpc"); + return ALL_OK.sorobanRpc; + }); + (indexerService.getCursor as jest.Mock).mockImplementation(async () => { + await new Promise((r) => setTimeout(r, 5)); + order.push("cursor"); + return 1000; + }); + + await request(makeApp({ probeDatabase, probeSorobanRpc })).get(URL); + + expect(probeDatabase).toHaveBeenCalledTimes(1); + expect(probeSorobanRpc).toHaveBeenCalledTimes(1); + expect(indexerService.getCursor).toHaveBeenCalledTimes(1); + expect(order).toHaveLength(3); + }); + + it("returns 500 and propagates to errorHandler when getCursor throws", async () => { + (indexerService.getCursor as jest.Mock).mockRejectedValue(new Error("DB exploded")); + + const res = await request( + makeApp({ + probeDatabase: () => Promise.resolve(ALL_OK.database), + probeSorobanRpc: () => Promise.resolve(ALL_OK.sorobanRpc), + }), + ).get(URL); + + expect(res.status).toBe(500); + }); + + it("returns 500 when probeDatabase throws", async () => { + const res = await request( + makeApp({ + probeDatabase: () => Promise.reject(new Error("database exploded")), + probeSorobanRpc: () => Promise.resolve(ALL_OK.sorobanRpc), + }), + ).get(URL); + + expect(res.status).toBe(500); + }); + + it("does not leak error field when probe is ok", async () => { + const res = await request( + makeApp({ + probeDatabase: () => Promise.resolve(ALL_OK.database), + probeSorobanRpc: () => Promise.resolve(ALL_OK.sorobanRpc), + }), + ).get(URL); + + expect(res.body.dependencies.database).not.toHaveProperty("error"); + expect(res.body.dependencies.sorobanRpc).not.toHaveProperty("error"); + }); +}); diff --git a/tests/indexerHealthEtag.test.ts b/tests/indexerHealthEtag.test.ts index 142bf139..b1bf05b8 100644 --- a/tests/indexerHealthEtag.test.ts +++ b/tests/indexerHealthEtag.test.ts @@ -7,9 +7,45 @@ process.env.PREDICTIFY_CONTRACT_ID = "CABCDEF"; process.env.ADMIN_ALLOWLIST = "G-ADMIN-ADDRESS-1,G-ADMIN-ADDRESS-2"; process.env.INDEXER_HEALTH_MAX_LAG = "50"; +// ── Module mocks ───────────────────────────────────────────────────────────── + +jest.mock("../src/services/indexerService", () => ({ + indexerService: { + getCursor: jest.fn(), + getChainTip: jest.fn(), + }, +})); + +// ── Imports ─────────────────────────────────────────────────────────────────── + import request from "supertest"; -import { createApp } from "../src/index"; +import express from "express"; +import { createIndexerHealthRouter } from "../src/routes/indexer/health"; import { indexerService } from "../src/services/indexerService"; +import { errorHandler } from "../src/middleware/errorHandler"; + +import type { ProbeResult } from "../src/routes/indexer/health"; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const ALL_OK = { + database: { status: "ok" as const, latencyMs: 2 }, + sorobanRpc: { status: "ok" as const, latencyMs: 5 }, +}; + +// ── App factory ─────────────────────────────────────────────────────────────── + +function makeApp(deps: { + probeDatabase?: () => Promise; + probeSorobanRpc?: () => Promise; +} = {}): express.Express { + const app = express(); + app.use("/api/indexer", createIndexerHealthRouter(deps)); + app.use(errorHandler); + return app; +} + +const URL = "/api/indexer/health"; /** * Focused tests for ETag / conditional-GET support on GET /api/indexer/health. @@ -30,62 +66,51 @@ import { indexerService } from "../src/services/indexerService"; * ✓ "down" status (RPC unavailable) also emits an ETag and honors If-None-Match */ describe("GET /api/indexer/health — ETag / conditional GET", () => { - let getCursor: jest.SpyInstance; - let getChainTip: jest.SpyInstance; - beforeEach(() => { - getCursor = jest.spyOn(indexerService, "getCursor"); - getChainTip = jest.spyOn(indexerService, "getChainTip"); + jest.clearAllMocks(); + (indexerService.getCursor as jest.Mock).mockResolvedValue(1000); + (indexerService.getChainTip as jest.Mock).mockResolvedValue(1010); }); afterEach(() => jest.restoreAllMocks()); - it("200 response includes a strong ETag header", async () => { - getCursor.mockResolvedValue(1000); - getChainTip.mockResolvedValue(1010); + const defaultDeps = { + probeDatabase: () => Promise.resolve(ALL_OK.database), + probeSorobanRpc: () => Promise.resolve(ALL_OK.sorobanRpc), + }; - const res = await request(createApp()).get("/api/indexer/health"); + it("200 response includes a strong ETag header", async () => { + const res = await request(makeApp(defaultDeps)).get(URL); expect(res.status).toBe(200); expect(res.headers["etag"]).toMatch(/^"[0-9a-f]{64}"$/); }); it("200 response includes Cache-Control: no-cache", async () => { - getCursor.mockResolvedValue(1000); - getChainTip.mockResolvedValue(1010); - - const res = await request(createApp()).get("/api/indexer/health"); + const res = await request(makeApp(defaultDeps)).get(URL); expect(res.status).toBe(200); expect(res.headers["cache-control"]).toBe("no-cache"); }); it("returns 304 when If-None-Match matches the current ETag", async () => { - getCursor.mockResolvedValue(1000); - getChainTip.mockResolvedValue(1010); - const first = await request(createApp()).get("/api/indexer/health"); + const first = await request(makeApp(defaultDeps)).get(URL); const etag = first.headers["etag"]; expect(etag).toBeDefined(); - getCursor.mockResolvedValue(1000); - getChainTip.mockResolvedValue(1010); - const second = await request(createApp()) - .get("/api/indexer/health") + const second = await request(makeApp(defaultDeps)) + .get(URL) .set("If-None-Match", etag); expect(second.status).toBe(304); }); it("304 response has no body", async () => { - getCursor.mockResolvedValue(1000); - getChainTip.mockResolvedValue(1010); - const first = await request(createApp()).get("/api/indexer/health"); + const first = await request(makeApp(defaultDeps)).get(URL); const etag = first.headers["etag"]; - getCursor.mockResolvedValue(1000); - getChainTip.mockResolvedValue(1010); - const second = await request(createApp()) - .get("/api/indexer/health") + const second = await request(makeApp(defaultDeps)) + .get(URL) .set("If-None-Match", etag); expect(second.status).toBe(304); @@ -93,15 +118,11 @@ describe("GET /api/indexer/health — ETag / conditional GET", () => { }); it("304 response still includes the ETag header", async () => { - getCursor.mockResolvedValue(1000); - getChainTip.mockResolvedValue(1010); - const first = await request(createApp()).get("/api/indexer/health"); + const first = await request(makeApp(defaultDeps)).get(URL); const etag = first.headers["etag"]; - getCursor.mockResolvedValue(1000); - getChainTip.mockResolvedValue(1010); - const second = await request(createApp()) - .get("/api/indexer/health") + const second = await request(makeApp(defaultDeps)) + .get(URL) .set("If-None-Match", etag); expect(second.status).toBe(304); @@ -109,11 +130,8 @@ describe("GET /api/indexer/health — ETag / conditional GET", () => { }); it("returns 200 when If-None-Match is stale", async () => { - getCursor.mockResolvedValue(1000); - getChainTip.mockResolvedValue(1010); - - const res = await request(createApp()) - .get("/api/indexer/health") + const res = await request(makeApp(defaultDeps)) + .get(URL) .set( "If-None-Match", '"0000000000000000000000000000000000000000000000000000000000000000"', @@ -124,46 +142,38 @@ describe("GET /api/indexer/health — ETag / conditional GET", () => { }); it("returns 200 when If-None-Match header is absent", async () => { - getCursor.mockResolvedValue(1000); - getChainTip.mockResolvedValue(1010); - - const res = await request(createApp()).get("/api/indexer/health"); + const res = await request(makeApp(defaultDeps)).get(URL); expect(res.status).toBe(200); }); - it("ETag is stable across repeated requests for unchanged cursor/chainTip", async () => { - getCursor.mockResolvedValue(1000); - getChainTip.mockResolvedValue(1010); - const r1 = await request(createApp()).get("/api/indexer/health"); - - getCursor.mockResolvedValue(1000); - getChainTip.mockResolvedValue(1010); - const r2 = await request(createApp()).get("/api/indexer/health"); + it("ETag is stable across repeated requests for unchanged state", async () => { + const r1 = await request(makeApp(defaultDeps)).get(URL); + const r2 = await request(makeApp(defaultDeps)).get(URL); expect(r1.headers["etag"]).toBe(r2.headers["etag"]); }); it("ETag changes when lag crosses into degraded", async () => { - getCursor.mockResolvedValue(1000); - getChainTip.mockResolvedValue(1010); - const ok = await request(createApp()).get("/api/indexer/health"); + (indexerService.getCursor as jest.Mock).mockResolvedValue(1000); + (indexerService.getChainTip as jest.Mock).mockResolvedValue(1010); + const ok = await request(makeApp(defaultDeps)).get(URL); - getCursor.mockResolvedValue(1000); - getChainTip.mockResolvedValue(2000); - const degraded = await request(createApp()).get("/api/indexer/health"); + (indexerService.getCursor as jest.Mock).mockResolvedValue(1000); + (indexerService.getChainTip as jest.Mock).mockResolvedValue(2000); + const degraded = await request(makeApp(defaultDeps)).get(URL); expect(ok.headers["etag"]).not.toBe(degraded.headers["etag"]); }); it("ETag changes when the chain tip advances (same status)", async () => { - getCursor.mockResolvedValue(1000); - getChainTip.mockResolvedValue(1010); - const first = await request(createApp()).get("/api/indexer/health"); + (indexerService.getCursor as jest.Mock).mockResolvedValue(1000); + (indexerService.getChainTip as jest.Mock).mockResolvedValue(1010); + const first = await request(makeApp(defaultDeps)).get(URL); - getCursor.mockResolvedValue(1005); - getChainTip.mockResolvedValue(1015); - const second = await request(createApp()).get("/api/indexer/health"); + (indexerService.getCursor as jest.Mock).mockResolvedValue(1005); + (indexerService.getChainTip as jest.Mock).mockResolvedValue(1015); + const second = await request(makeApp(defaultDeps)).get(URL); expect(first.body.data.status).toBe("ok"); expect(second.body.data.status).toBe("ok"); @@ -171,15 +181,15 @@ describe("GET /api/indexer/health — ETag / conditional GET", () => { }); it("sending a stale ETag after state changes returns 200, not 304", async () => { - getCursor.mockResolvedValue(1000); - getChainTip.mockResolvedValue(1010); - const first = await request(createApp()).get("/api/indexer/health"); + (indexerService.getCursor as jest.Mock).mockResolvedValue(1000); + (indexerService.getChainTip as jest.Mock).mockResolvedValue(1010); + const first = await request(makeApp(defaultDeps)).get(URL); const staleEtag = first.headers["etag"]; - getCursor.mockResolvedValue(1000); - getChainTip.mockResolvedValue(2000); - const second = await request(createApp()) - .get("/api/indexer/health") + (indexerService.getCursor as jest.Mock).mockResolvedValue(1000); + (indexerService.getChainTip as jest.Mock).mockResolvedValue(2000); + const second = await request(makeApp(defaultDeps)) + .get(URL) .set("If-None-Match", staleEtag); expect(second.status).toBe(200); @@ -187,19 +197,19 @@ describe("GET /api/indexer/health — ETag / conditional GET", () => { }); it("'down' status also emits an ETag and honors If-None-Match", async () => { - getCursor.mockResolvedValue(1000); - getChainTip.mockRejectedValue(new Error("rpc unavailable")); - const first = await request(createApp()).get("/api/indexer/health"); + (indexerService.getCursor as jest.Mock).mockResolvedValue(1000); + (indexerService.getChainTip as jest.Mock).mockRejectedValue(new Error("rpc unavailable")); + const first = await request(makeApp(defaultDeps)).get(URL); expect(first.status).toBe(200); expect(first.body.data.status).toBe("down"); const etag = first.headers["etag"]; expect(etag).toMatch(/^"[0-9a-f]{64}"$/); - getCursor.mockResolvedValue(1000); - getChainTip.mockRejectedValue(new Error("rpc unavailable")); - const second = await request(createApp()) - .get("/api/indexer/health") + (indexerService.getCursor as jest.Mock).mockResolvedValue(1000); + (indexerService.getChainTip as jest.Mock).mockRejectedValue(new Error("rpc unavailable")); + const second = await request(makeApp(defaultDeps)) + .get(URL) .set("If-None-Match", etag); expect(second.status).toBe(304); From 1b124efda32254f06d98bbe12bf4e3203647002f Mon Sep 17 00:00:00 2001 From: adesuwa-tech Date: Mon, 27 Jul 2026 05:12:45 +0000 Subject: [PATCH 2/3] fix: resolve pre-existing CI failures (lint errors, missing imports, parsing errors) --- src/index.ts | 3 -- src/metrics/registry.ts | 6 +++ src/middleware/rateLimit.ts | 17 ++++++++- src/middleware/timeout.ts | 1 - src/routes/admin/audit.ts | 1 - src/routes/auth.ts | 3 +- src/routes/markets/index.ts | 4 -- src/routes/markets/watchers.ts | 2 +- src/routes/predictions.ts | 7 +--- src/routes/users.ts | 18 +-------- src/routes/webhooks.ts | 56 +++------------------------- src/services/marketWatcherService.ts | 2 +- 12 files changed, 34 insertions(+), 86 deletions(-) diff --git a/src/index.ts b/src/index.ts index fec00dbe..0e804f75 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,13 +31,11 @@ import { createDocsRouter } from "./routes/docs"; import { sessionsRouter } from "./routes/me/sessions"; import { notificationsRouter } from "./routes/notifications"; import { socialRouter } from "./routes/social"; -import { webhooksRouter } from "./routes/webhooks"; import { webhooksHealthRouter } from "./routes/webhooks/health"; import { adminAuditRouter } from "./routes/admin/audit"; import { adminAuditExportRouter } from "./routes/admin/audit/export"; import { auditCountsRouter } from "./routes/audit/counts"; import { adminMarketsRouter } from "./routes/admin/markets"; -import { adminReconciliationRouter } from "./routes/admin/reconciliation"; import { adminSchemaVersionsRouter } from "./routes/admin/schema-versions"; import { errorHandler } from "./middleware/errorHandler"; import { requestContextStorage } from "./lib/requestContext"; @@ -53,7 +51,6 @@ import { backupVerificationWorker } from "./workers/backupVerificationWorker"; import { reconciliationWorker } from "./workers/reconciliationWorker"; import { rateLimitRouter } from "./routes/rate-limit"; import { adminRateLimitInspectRouter } from "./routes/admin/rate-limit/inspect"; -import { forceResolveRouter } from "./routes/admin/force-resolve"; import { quotaRequestsRouter } from "./routes/quota/requests"; import { startSlowQueryAlerter, stopSlowQueryAlerter } from "./workers/slowQueryAlerter"; import { reportsRouter } from "./routes/reports"; diff --git a/src/metrics/registry.ts b/src/metrics/registry.ts index 28149677..5b828e04 100644 --- a/src/metrics/registry.ts +++ b/src/metrics/registry.ts @@ -32,6 +32,12 @@ export const indexerPollsTotal = new Counter({ registers: [register], }); +export const indexerLagLedgers = new Gauge({ + name: "indexer_lag_ledgers", + help: "Current lag in ledgers between the indexer cursor and the chain tip", + registers: [register], +}); + export const webhookDeliveriesTotal = new Counter({ name: "webhook_deliveries_total", help: "Total number of webhook deliveries, segmented by outcome status (success, failed)", diff --git a/src/middleware/rateLimit.ts b/src/middleware/rateLimit.ts index e60fed6c..fa500a29 100644 --- a/src/middleware/rateLimit.ts +++ b/src/middleware/rateLimit.ts @@ -24,7 +24,6 @@ import rateLimit, { type Options, type RateLimitRequestHandler } from "express-r import type { NextFunction, Request, Response } from "express"; import { v4 as uuidv4 } from "uuid"; import { createAuditLog, type RateLimitContext } from "../services/auditService"; -import { logger } from "../config/logger"; import { env } from "../config/env"; declare global { @@ -281,6 +280,22 @@ export function createPerUserRateLimiter(options: Partial = {}): RateLi }); } +export function createUserRateLimiter(options: Partial = {}): RateLimitRequestHandler { + return createRateLimiter({ + windowMs: 15 * 60 * 1000, + limit: 100, + keyGenerator: (req) => { + const authReq = req as AuthenticatedRequest; + const address = authReq.user?.address ?? authReq.user?.sub; + if (typeof address === "string" && address.trim().length > 0) { + return `user:${address}`; + } + return `ip:${getClientIp(req)}`; + }, + ...options, + }); +} + /** * Pre-configured per-user rate limiter for `/api/webhooks` routes. * diff --git a/src/middleware/timeout.ts b/src/middleware/timeout.ts index a7cfbf99..54bbaee8 100644 --- a/src/middleware/timeout.ts +++ b/src/middleware/timeout.ts @@ -1,5 +1,4 @@ import { Request, Response, NextFunction } from "express"; -import { logger } from "../config/logger"; export interface RequestTimeoutOptions { /** HTTP status code to send when the timeout is exceeded. Defaults to 408. */ diff --git a/src/routes/admin/audit.ts b/src/routes/admin/audit.ts index 60ca0160..e9a805b1 100644 --- a/src/routes/admin/audit.ts +++ b/src/routes/admin/audit.ts @@ -4,7 +4,6 @@ import { z } from "zod"; import { requireAdmin } from "../../middleware/requireAdmin"; import { getAuditLogs } from "../../repositories/auditLogRepo"; import { RouteErrorFactory } from "../../errors"; -import { startAuditSpan, endAuditSpan, recordErrorOnSpan } from "../../otel/spans"; import { searchAuditLogsHandler } from "./audit/search"; export interface AdminAuditRouterOptions { diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 551b6b25..35cbe4e1 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -1,6 +1,5 @@ -import { Router, Request, Response, NextFunction } from "express"; +import { Router } from "express"; import { z } from "zod"; -import { and, eq, isNull, gt, desc, or, lt } from "drizzle-orm"; import { StrKey } from "@stellar/stellar-sdk"; import { conditionalGet } from "../middleware/etag"; import { createPerUserRateLimiter } from "../middleware/rateLimit"; diff --git a/src/routes/markets/index.ts b/src/routes/markets/index.ts index ce2c5782..72cb64ed 100644 --- a/src/routes/markets/index.ts +++ b/src/routes/markets/index.ts @@ -1,5 +1,4 @@ import { Router } from "express"; -import type { Request, Response, NextFunction } from "express"; import { listMarkets, listUpcomingMarkets, @@ -9,15 +8,12 @@ import { } from "../../services/marketService"; import { searchMarkets } from "../../repositories/marketRepository"; import { requireAdmin, AuthenticatedRequest } from "../../middleware/auth"; -import { createPerUserRateLimiter } from "../../middleware/rateLimit"; import { rateLimitAnon } from "../../middleware/rateLimitAnon"; import { accessLog } from "../../middleware/accessLog"; import { listFeaturedMarkets } from "../../services/marketFeatureService"; import { logger } from "../../config/logger"; -import { accessLog } from "../../middleware/accessLog"; import { RouteErrorFactory } from "../../errors"; import { conditionalGet } from "../../middleware/etag"; -import { marketsRequestDuration, marketsRequestsTotal } from "../../metrics/registry"; import { recommendationsRouter } from "./recommendations"; import { trendingRouter } from "./trending"; import { tagsRouter } from "./tags"; diff --git a/src/routes/markets/watchers.ts b/src/routes/markets/watchers.ts index a9227d88..ae1388ea 100644 --- a/src/routes/markets/watchers.ts +++ b/src/routes/markets/watchers.ts @@ -15,7 +15,7 @@ import { Router } from "express"; import { logger } from "../../config/logger"; -import { NotFoundError, RouteErrorFactory } from "../../errors"; +import { NotFoundError } from "../../errors"; import { getRequestId } from "../../lib/requestContext"; import { AuthenticatedRequest, requireAuth } from "../../middleware/auth"; import { diff --git a/src/routes/predictions.ts b/src/routes/predictions.ts index 55e05042..d252814f 100644 --- a/src/routes/predictions.ts +++ b/src/routes/predictions.ts @@ -1,16 +1,13 @@ import { Router, Request, Response, NextFunction } from "express"; -import { accessLog } from "../middleware/accessLog"; import { requireAuth } from "../middleware/requireAuth"; -import { accessLog } from "../middleware/accessLog"; import { createPerUserRateLimiter } from "../middleware/rateLimit"; -import { accessLog } from "../middleware/accessLog"; import { getPredictionExplanation } from "../services/predictionExplainService"; import cancelRouter from "./predictions/cancel"; import { createShareRouter } from "./predictions/share"; import { listPredictions } from "../repositories/predictionRepo"; import { logger } from "../config/logger"; import { getRequestId } from "../lib/requestContext"; -import { clampLimit, DEFAULT_PAGE_SIZE } from "../utils/cursor"; +import { clampLimit } from "../utils/cursor"; import { predictionsListTotal, predictionExplainTotal, @@ -19,7 +16,7 @@ import { import { clampLimit } from "../utils/cursor"; import type { AuthenticatedRequest } from "../middleware/auth"; import { listPredictionsQuerySchema } from "../validators/predictions"; -import { accessLog } from "../middleware/accessLog"; +import { requestTimeout } from "../middleware/timeout"; export const predictionsRouter = Router(); diff --git a/src/routes/users.ts b/src/routes/users.ts index 63004ee0..47a110c4 100644 --- a/src/routes/users.ts +++ b/src/routes/users.ts @@ -35,10 +35,11 @@ import { getUserByAddress, getUserPredictions, getCurrentUserProfile, getUserPro import { requireAuthForbidden } from "../middleware/requireAuth"; import { AuthenticatedRequest } from "../middleware/auth"; import { accessLog } from "../middleware/accessLog"; +import { createPerUserRateLimiter } from "../middleware/rateLimit"; import { conditionalGet } from "../middleware/etag"; import { logger } from "../config/logger"; import { getRequestId } from "../lib/requestContext"; -import { clampLimit } from "../utils/cursor"; +import { clampLimit, DEFAULT_PAGE_SIZE } from "../utils/cursor"; import { RouteErrorFactory } from "../errors"; import { requestTimeout } from "../middleware/timeout"; import { usersMetricsMiddleware } from "../metrics/usersMetrics"; @@ -50,11 +51,6 @@ import { export const usersRouter = Router(); -/** Zod schema for a valid Stellar public key (56-char G… address). */ -const stellarAddressSchema = z - .string() - .regex(/^G[A-Z2-7]{55}$/, "Invalid Stellar address"); - /** * Shared /api/users limiter. Authenticated requests (req.user set) use * `users:{id}`; anonymous traffic uses `users:ip:{ip}`. @@ -87,16 +83,6 @@ usersRouter.use(requestTimeout(15000)); // 15 seconds timeout // --------------------------------------------------------------------------- usersRouter.use(usersMetricsMiddleware); -// --------------------------------------------------------------------------- -// GET /api/users/me -// --------------------------------------------------------------------------- -usersRouter.get( - "/me", - requireAuthForbidden, - usersRateLimit, - async (req: AuthenticatedRequest, res, next) => { - const correlationId = res.locals.correlationId as string; - /** * GET /api/users * diff --git a/src/routes/webhooks.ts b/src/routes/webhooks.ts index 8205a50d..fe2fa567 100644 --- a/src/routes/webhooks.ts +++ b/src/routes/webhooks.ts @@ -26,67 +26,21 @@ import { logger } from "../config/logger"; import { getRequestId } from "../lib/requestContext"; import { webhookCors } from "../middleware/cors"; import { requireAdmin } from "../middleware/requireAdmin"; -import { webhooksMetricsMiddleware } from "../metrics/webhooksMetrics"; -import type { WebhookDelivery, WebhookStore } from "../services/webhookStore"; -import { listWebhooksQuerySchema } from "../validators/webhooks"; // --------------------------------------------------------------------------- // Zod schemas — boundary validation // --------------------------------------------------------------------------- -function serializeDelivery(row: WebhookDelivery) { - return { - id: row.id, - url: row.url, - events: row.events as readonly string[], - active: row.active, - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - }; -} - // --------------------------------------------------------------------------- // Router // --------------------------------------------------------------------------- - // Enforce CORS allowlist before admin auth so unapproved origins are - // rejected early without leaking auth challenge details. - router.use(webhookCors()); - router.use(requireAdmin); - - router.get("/", async (req, res, next) => { - const endTimer = webhookRequestDuration.startTimer({ - route: "/api/webhooks", - }); - - res.on("finish", () => { - endTimer(); - }); +export const webhooksRouter = Router(); - const requestId = getRequestId(); - - try { - const parseResult = listWebhooksQuerySchema.safeParse(req.query); - if (!parseResult.success) { - const issue = parseResult.error.issues[0]; - logger.warn( - { - event: "webhooks_list_validation_failed", - requestId, - adminAddress: req.adminAddress, - issues: parseResult.error.issues, - }, - "Webhook list: invalid query parameters", - ); - res.status(400).json({ - error: { - code: "validation_error", - message: issue?.message ?? "invalid query parameters", - requestId, - }, - }); - return; - } +// Enforce CORS allowlist before admin auth so unapproved origins are +// rejected early without leaking auth challenge details. +webhooksRouter.use(webhookCors()); +webhooksRouter.use(requireAdmin); webhooksRouter.get("/", async (req, res, next) => { const reqId = getRequestId(); diff --git a/src/services/marketWatcherService.ts b/src/services/marketWatcherService.ts index 6b49dd46..7b930dc1 100644 --- a/src/services/marketWatcherService.ts +++ b/src/services/marketWatcherService.ts @@ -5,7 +5,7 @@ */ import { and, desc, eq, lt, or, sql } from "drizzle-orm"; -import { db, getDb } from "../db/client"; +import { getDb } from "../db/client"; import { markets, marketWatchers, users } from "../db/schema"; import { NotFoundError } from "../errors"; import { clampLimit, decodeCursor, encodeCursor, type Page } from "../utils/cursor"; From d87644898b3e02d021c07925297781f6e2e753fa Mon Sep 17 00:00:00 2001 From: adesuwa-tech Date: Mon, 27 Jul 2026 05:24:31 +0000 Subject: [PATCH 3/3] fix: resolve remaining CI issues (users metrics, __tests__ lint, correlationId) --- src/__tests__/routes/markets/health.test.ts | 3 ++- src/__tests__/routes/subscriptions.test.ts | 5 +++-- src/metrics/registry.ts | 15 +++++++++++++++ src/routes/users.ts | 1 + 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/__tests__/routes/markets/health.test.ts b/src/__tests__/routes/markets/health.test.ts index 453700aa..01ea0603 100644 --- a/src/__tests__/routes/markets/health.test.ts +++ b/src/__tests__/routes/markets/health.test.ts @@ -1,5 +1,6 @@ import request from "supertest"; import express from "express"; +import type { Request, Response, NextFunction } from "express"; import { createMarketsHealthRouter } from "../../../../src/routes/markets/health"; const MOCK_HEALTH = { @@ -63,7 +64,7 @@ describe("Markets Health Router (v7)", () => { })); // Add a basic error handler to catch it - app.use((err: any, req: any, res: any, next: any) => { + app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { res.status(500).json({ error: err.message }); }); diff --git a/src/__tests__/routes/subscriptions.test.ts b/src/__tests__/routes/subscriptions.test.ts index f7e5feae..efdf10c6 100644 --- a/src/__tests__/routes/subscriptions.test.ts +++ b/src/__tests__/routes/subscriptions.test.ts @@ -1,12 +1,13 @@ import { describe, it, expect, beforeEach, jest } from "@jest/globals"; import request from "supertest"; import express from "express"; +import type { Request, Response, NextFunction } from "express"; import { subscriptionsRouter } from "../../routes/subscriptions"; import { db } from "../../db/client"; import { generateETag } from "../../middleware/etag"; jest.mock("../../middleware/requireAdmin", () => ({ - requireAdmin: (req: any, res: any, next: any) => next(), + requireAdmin: (_req: Request, _res: Response, next: NextFunction) => next(), })); jest.mock("../../db/client", () => { @@ -79,7 +80,7 @@ describe("Subscriptions Routes", () => { it("should handle db errors", async () => { (db.from as jest.Mock).mockRejectedValueOnce(new Error("Database error")); // Mute the express default error handler output in tests - app.use((err: any, req: any, res: any, next: any) => { + app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { res.status(500).json({ error: "Internal Error" }); }); diff --git a/src/metrics/registry.ts b/src/metrics/registry.ts index 5b828e04..bac9bfe2 100644 --- a/src/metrics/registry.ts +++ b/src/metrics/registry.ts @@ -111,3 +111,18 @@ export const webhooksEndpointDuration = new Histogram({ buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5, 10], registers: [register], }); + +export const usersEndpointRequestsTotal = new Counter({ + name: "users_endpoint_requests_total", + help: "Total number of requests to /api/users endpoints, segmented by method, route, and status", + labelNames: ["method", "route", "status"] as const, + registers: [register], +}); + +export const usersEndpointDuration = new Histogram({ + name: "users_endpoint_duration_seconds", + help: "Request duration in seconds for /api/users endpoints, segmented by method, route, and status", + labelNames: ["method", "route", "status"] as const, + buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5, 10], + registers: [register], +}); diff --git a/src/routes/users.ts b/src/routes/users.ts index 47a110c4..db4a4589 100644 --- a/src/routes/users.ts +++ b/src/routes/users.ts @@ -146,6 +146,7 @@ usersRouter.get("/", async (req: Request, res: Response, next: NextFunction) => }); usersRouter.get("/me", requireAuthForbidden, async (req: AuthenticatedRequest, res, next) => { + const correlationId = (res.locals.correlationId as string | undefined) ?? getRequestId(); try { const userId = req.user!.id; const result = await getCurrentUserProfile(userId);