diff --git a/server/openapi.json b/server/openapi.json index fd3d602..6444710 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -1146,7 +1146,7 @@ "Auth" ], "summary": "Register a new phone number after recovery", - "description": "Presents the one-time recovery token from POST /api/v1/auth/recover and moves the account to a new phone number (issue #108). Returns a fresh session JWT so the user is signed in immediately.", + "description": "Presents the one-time recovery token from POST /api/v1/auth/recover and moves the account to a new phone number. Sends an OTP to the new phone number and prompts the user to verify ownership via POST /api/v1/auth/verify-otp before a session JWT is issued.", "security": [], "requestBody": { "required": true, @@ -1160,11 +1160,22 @@ }, "responses": { "200": { - "description": "Phone number updated — session JWT issued.", + "description": "Phone number updated — verification prompt returned.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AuthTokenResponse" + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "requiresVerification": { + "type": "boolean" + } + } } } } @@ -1299,6 +1310,17 @@ "bearerAuth": [] } ], + "parameters": [ + { + "name": "Idempotency-Key", + "in": "header", + "required": false, + "description": "Optional idempotency key to prevent duplicate listing creation on retry.", + "schema": { + "type": "string" + } + } + ], "requestBody": { "required": true, "content": { diff --git a/server/src/config/validateEnv.test.ts b/server/src/config/validateEnv.test.ts index f2c3d24..7571466 100644 --- a/server/src/config/validateEnv.test.ts +++ b/server/src/config/validateEnv.test.ts @@ -83,6 +83,25 @@ describe("validateEnv", () => { expect(result.missingOptional).toEqual(expect.arrayContaining([...OPTIONAL_ENV_VARS])); }); + it("requires REDIS_URL when NODE_ENV is production", () => { + const prodEnvWithoutRedis = { + ...validFullEnv, + NODE_ENV: "production", + REDIS_URL: "", + }; + + const result = validateEnv(prodEnvWithoutRedis); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes("REDIS_URL is required in production"))).toBe(true); + + const prodEnvWithRedis = { + ...validFullEnv, + NODE_ENV: "production", + REDIS_URL: "redis://localhost:6379", + }; + expect(validateEnv(prodEnvWithRedis).valid).toBe(true); + }); + describe("assertEnvValid", () => { it("throws an error when required variables are missing", () => { expect(() => assertEnvValid({})).toThrow(/Missing required environment variables/); diff --git a/server/src/config/validateEnv.ts b/server/src/config/validateEnv.ts index 942a1f9..4c9f293 100644 --- a/server/src/config/validateEnv.ts +++ b/server/src/config/validateEnv.ts @@ -76,6 +76,12 @@ export function validateEnv( } } + if (env["NODE_ENV"] === "production" && !env["REDIS_URL"]) { + errors.push( + "[startup] REDIS_URL is required in production to ensure rate limiting and velocity checks persist across restarts and replicas" + ); + } + return { valid: errors.length === 0, missingRequired, diff --git a/server/src/index.ts b/server/src/index.ts index 4a07023..15dcfe7 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -13,6 +13,7 @@ import { errorHandler } from "./middleware/errorHandler"; import { apiVersion } from "./middleware/apiVersion"; import { requestId } from "./middleware/requestId"; import { pool, query } from "./db/pool"; +import { cache } from "./services/cache"; import { assertEnvValid } from "./config/validateEnv"; @@ -48,6 +49,26 @@ if (!isTest) { }); } +// --------------------------------------------------------------------------- +// Redis connection test on startup +// --------------------------------------------------------------------------- + +if (!isTest && (process.env["NODE_ENV"] === "production" || process.env["REDIS_URL"])) { + cache + .checkReady() + .then(() => { + logger.info("[cache] Redis connection validated at startup"); + }) + .catch((err) => { + logger.fatal( + `[startup] FATAL: Redis is unavailable at startup: ${(err as Error).message}\n` + + "Verify REDIS_URL is reachable. Server exiting." + ); + console.error(`[FATAL] Redis is unavailable at startup: ${(err as Error).message}`); + process.exit(1); + }); +} + // --------------------------------------------------------------------------- // App setup // --------------------------------------------------------------------------- diff --git a/server/src/openapi.ts b/server/src/openapi.ts index a23bbd0..111dd1b 100644 --- a/server/src/openapi.ts +++ b/server/src/openapi.ts @@ -852,8 +852,9 @@ export const openApiDocument = { summary: "Register a new phone number after recovery", description: "Presents the one-time recovery token from POST /api/v1/auth/recover " + - "and moves the account to a new phone number (issue #108). Returns a " + - "fresh session JWT so the user is signed in immediately.", + "and moves the account to a new phone number. Sends an OTP to the new phone " + + "number and prompts the user to verify ownership via POST /api/v1/auth/verify-otp " + + "before a session JWT is issued.", security: [], requestBody: { required: true, @@ -865,10 +866,17 @@ export const openApiDocument = { }, responses: { "200": { - description: "Phone number updated — session JWT issued.", + description: "Phone number updated — verification prompt returned.", content: { "application/json": { - schema: { $ref: "#/components/schemas/AuthTokenResponse" }, + schema: { + type: "object", + properties: { + message: { type: "string" }, + phone: { type: "string" }, + requiresVerification: { type: "boolean" }, + }, + }, }, }, }, @@ -963,6 +971,15 @@ export const openApiDocument = { "Creates a new trade listing. Calls `create_listing` on the Soroban escrow contract " + "and stores the resulting on-chain listing ID.", security: [{ bearerAuth: [] }], + parameters: [ + { + name: "Idempotency-Key", + in: "header", + required: false, + description: "Optional idempotency key to prevent duplicate listing creation on retry.", + schema: { type: "string" }, + }, + ], requestBody: { required: true, content: { diff --git a/server/src/routes/admin.test.ts b/server/src/routes/admin.test.ts new file mode 100644 index 0000000..43bd895 --- /dev/null +++ b/server/src/routes/admin.test.ts @@ -0,0 +1,84 @@ +import request from "supertest"; +import express from "express"; +import jwt from "jsonwebtoken"; + +const JWT_SECRET = "test-secret-key-12345678901234567890123456789012"; +process.env["JWT_SECRET"] = JWT_SECRET; + +const mockQuery = jest.fn(); +jest.mock("../db", () => ({ + __esModule: true, + default: { + query: (...args: any[]) => mockQuery(...args), + connect: jest.fn(), + }, +})); + +jest.mock("../services/stellar", () => ({ + resolveDispute: jest.fn(), +})); + +jest.mock("../services/fraudDetection", () => ({ + FraudDetectionService: { + getFlaggedAccounts: jest.fn(), + }, +})); + +import adminRouter from "./admin"; + +const app = express(); +app.use(express.json()); +app.use("/api/v1/admin", adminRouter); + +describe("GET /api/v1/admin/users/:phone", () => { + const adminId = "99999999-9999-9999-9999-999999999999"; + const adminToken = jwt.sign( + { sub: adminId, role: "admin", tokenVersion: 1 }, + JWT_SECRET, + { expiresIn: "1h" } + ); + + beforeEach(() => { + mockQuery.mockReset(); + }); + + it("normalises phone parameter and returns maskedPhone and phoneHash instead of raw phone", async () => { + // 1st query: authenticate token_version check + mockQuery.mockResolvedValueOnce({ rows: [{ token_version: 1 }] }); + // 2nd query: authorize role check + mockQuery.mockResolvedValueOnce({ rows: [{ role: "admin" }] }); + // 3rd query: SELECT user WHERE phone = $1 + mockQuery.mockResolvedValueOnce({ + rows: [ + { + id: "user-123", + phone: "+2348012345678", + role: "user", + created_at: "2026-01-01T00:00:00.000Z", + stellar_public_key: null, + balance: 5000, + }, + ], + }); + // 4th query: SELECT trade_offers + mockQuery.mockResolvedValueOnce({ rows: [] }); + + // Request with unnormalized local phone number + const res = await request(app) + .get("/api/v1/admin/users/08012345678") + .set("Authorization", `Bearer ${adminToken}`); + + expect(res.status).toBe(200); + // User query was called with normalized E.164 phone number (+2348012345678) + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("WHERE u.phone = $1"), + ["+2348012345678"] + ); + + // Response contains maskedPhone and phoneHash, not raw phone + expect(res.body.data.maskedPhone).toBe("+234 *** *** 5678"); + expect(res.body.data.phoneHash).toBeDefined(); + expect(res.body.data.phoneHash.length).toBe(8); + expect(res.body.data.phone).toBeUndefined(); + }); +}); diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index e1e0429..271a615 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -14,9 +14,11 @@ */ import { Router } from "express"; +import { createHash } from "crypto"; import { authenticate } from "../middleware/authenticate"; import { authorize } from "../middleware/authorize"; import { validate } from "../middleware/validate"; +import { normalizePhone, maskPhone } from "../utils/phone"; import { QueueService } from "../jobs"; import { SseEmitter } from "../services/sseEmitter"; import pool from "../db"; @@ -368,6 +370,7 @@ router.get( authorize("admin"), async (req, res) => { const { phone } = req.params; + const normalizedPhone = normalizePhone(phone); const { rows: userRows } = await pool.query<{ id: string; @@ -391,7 +394,7 @@ router.get( FROM users u WHERE u.phone = $1 LIMIT 1`, - [phone] + [normalizedPhone] ); if (!userRows.length) { @@ -408,10 +411,16 @@ router.get( [user.id] ); + const phoneHash = createHash("sha256") + .update(user.phone) + .digest("hex") + .slice(0, 8); + res.status(200).json({ data: { id: user.id, - phone: user.phone, + maskedPhone: maskPhone(user.phone), + phoneHash, role: user.role, registrationDate: user.created_at, stellarPublicKey: user.stellar_public_key, diff --git a/server/src/routes/auth.recover.test.ts b/server/src/routes/auth.recover.test.ts new file mode 100644 index 0000000..c7cf74f --- /dev/null +++ b/server/src/routes/auth.recover.test.ts @@ -0,0 +1,103 @@ +/** + * auth.recover.test.ts + * + * Tests for POST /api/v1/auth/recover/change-phone (issue #252). + */ + +import request from "supertest"; +import express from "express"; +import jwt from "jsonwebtoken"; + +const JWT_SECRET = "test-secret-key-at-least-32-chars-long!"; +process.env["JWT_SECRET"] = JWT_SECRET; +process.env["TERMII_API_KEY"] = "test-termii-api-key"; + +const mockQuery = jest.fn(); +jest.mock("../db", () => ({ + __esModule: true, + default: { + query: (...args: any[]) => mockQuery(...args), + }, +})); + +jest.mock("../services/stellar", () => ({ + generateAndFundWallet: jest.fn(), +})); + +jest.mock("../services/recoveryCodes", () => ({ + generateRecoveryCodesForUser: jest.fn(), + userHasRecoveryCodes: jest.fn(), + countRemainingRecoveryCodes: jest.fn(), + redeemRecoveryCode: jest.fn(), +})); + +// Mock fetch for Termii OTP send +const mockFetch = jest.fn(); +global.fetch = mockFetch as unknown as typeof fetch; + +import authRouter from "./auth"; + +const app = express(); +app.use(express.json()); +app.use("/api/v1/auth", authRouter); + +describe("POST /api/v1/auth/recover/change-phone", () => { + const userId = "aaaaaaaa-0000-0000-0000-000000000001"; + const validRecoveryToken = jwt.sign( + { sub: userId, scope: "recovery", purpose: "change-phone" }, + JWT_SECRET, + { expiresIn: "15m" } + ); + + beforeEach(() => { + mockQuery.mockReset(); + mockFetch.mockReset(); + }); + + it("returns verification prompt without session token, sends OTP, and invalidates recovery token", async () => { + // Termii mock returns pinId + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ pinId: "pin-123456", status: "success" }), + }); + + // 1st query: check if newPhone is already registered + mockQuery.mockResolvedValueOnce({ rows: [] }); + // 2nd query: check if user exists + mockQuery.mockResolvedValueOnce({ rows: [{ id: userId }] }); + // 3rd query: UPDATE users SET phone = $1, token_version = token_version + 1 WHERE id = $2 + mockQuery.mockResolvedValueOnce({ rows: [] }); + // 4th query: sendOtp updates users table with otp_pin_id + mockQuery.mockResolvedValueOnce({ rows: [] }); + + const newPhone = "+2348123456789"; + + const res = await request(app) + .post("/api/v1/auth/recover/change-phone") + .send({ token: validRecoveryToken, newPhone }); + + expect(res.status).toBe(200); + // Does NOT return a session token + expect(res.body.token).toBeUndefined(); + expect(res.body.requiresVerification).toBe(true); + expect(res.body.phone).toBe(newPhone); + expect(res.body.message).toContain("Please verify your new phone number with the OTP"); + + // Verified OTP was sent via Termii + expect(mockFetch).toHaveBeenCalledWith( + "https://api.ng.termii.com/api/sms/otp/send", + expect.objectContaining({ + method: "POST", + body: expect.stringContaining(newPhone), + }) + ); + + // Second attempt with the SAME recovery token must fail because it was invalidated immediately + const resDuplicate = await request(app) + .post("/api/v1/auth/recover/change-phone") + .send({ token: validRecoveryToken, newPhone }); + + expect(resDuplicate.status).toBe(401); + expect(resDuplicate.body.error).toContain("Recovery token is invalid or expired"); + }); +}); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 2da8d85..9e7ff6a 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -8,9 +8,10 @@ import { RECOVERY_WINDOW_MINUTES, } from "../middleware/rateLimitRecovery"; import { v4 as uuidv4 } from "uuid"; -import { randomInt } from "crypto"; +import { randomInt, createHash } from "crypto"; import jwt from "jsonwebtoken"; import pool from "../db"; +import { cache } from "../services/cache"; import { generateAndFundWallet } from "../services/stellar"; import { validate } from "../middleware/validate"; import { @@ -410,6 +411,13 @@ router.post( return; } + const tokenHash = createHash("sha256").update(token).digest("hex"); + const isRevoked = await cache.get(`revoked_recovery_token:${tokenHash}`); + if (isRevoked) { + res.status(401).json({ error: "Recovery token is invalid or expired" }); + return; + } + const userId = payload.sub; // The new phone must not belong to another account. @@ -434,31 +442,29 @@ router.post( return; } + // Update phone number and bump token version await pool.query( - `UPDATE users SET phone = $1, updated_at = NOW() WHERE id = $2`, + `UPDATE users SET phone = $1, token_version = token_version + 1, updated_at = NOW() WHERE id = $2`, [newPhone, userId] ); - // Issue a normal session JWT so the user is signed in immediately. - const { rows: keyRows } = await pool.query<{ - stellar_public_key: string | null; - token_version: number; - }>( - `SELECT stellar_public_key, token_version FROM users WHERE id = $1 LIMIT 1`, - [userId] - ); - const stellarPublicKey = keyRows[0]?.stellar_public_key ?? ""; - const tokenVersion = keyRows[0]?.token_version ?? 1; + // Invalidate the recovery token immediately so it cannot be reused + await cache.set(`revoked_recovery_token:${tokenHash}`, "1", 15 * 60); - const sessionToken = jwt.sign( - { sub: userId, stellarPublicKey, tokenVersion }, - secret, - { expiresIn: "7d" } - ); + // Send OTP to new phone number to verify ownership + try { + await sendOtp(newPhone, ""); + } catch (err) { + console.error("[auth] Failed to send OTP to new phone:", (err as Error).message); + res.status(502).json({ error: "Failed to send OTP to new phone. Please try again." }); + return; + } + // Do NOT issue a session JWT yet — the user must complete OTP verification for the new number res.status(200).json({ - token: sessionToken, - user: { id: userId, phone: newPhone, stellarPublicKey }, + message: "Phone number updated. Please verify your new phone number with the OTP sent to complete sign-in.", + phone: newPhone, + requiresVerification: true, }); } ); diff --git a/server/src/routes/profile.ts b/server/src/routes/profile.ts index a579816..01d0413 100644 --- a/server/src/routes/profile.ts +++ b/server/src/routes/profile.ts @@ -5,6 +5,7 @@ import { authenticate, AuthenticatedRequest } from "../middleware/authenticate"; import { QueueService } from "../jobs"; import type { TradeOffer, TradeStatus } from "../types/trade"; import logger from "../utils/logger"; +import { maskPhone } from "../utils/phone"; const router = Router(); @@ -12,14 +13,6 @@ const router = Router(); // Helpers // --------------------------------------------------------------------------- -/** Mask a phone number — show country code + last 4 digits only: +234 *** *** 7890 */ -function maskPhone(phone: string): string { - if (phone.length < 6) return "***"; - const visible = phone.slice(-4); - const prefix = phone.startsWith("+") ? phone.slice(0, 4) : phone.slice(0, 3); - return `${prefix} *** *** ${visible}`; -} - /** * One-way SHA-256 hash of a phone number. * Used to replace PII on deletion while preserving a non-reversible fingerprint diff --git a/server/src/routes/trades.create.test.ts b/server/src/routes/trades.create.test.ts index 45fa143..b53952a 100644 --- a/server/src/routes/trades.create.test.ts +++ b/server/src/routes/trades.create.test.ts @@ -213,4 +213,30 @@ describe("POST /api/v1/trades — explicit stroop handling (issue #292)", () => expect(res.status).toBe(422); expect(mockCreateListing).not.toHaveBeenCalled(); }); + + it("returns cached response on duplicate request with same Idempotency-Key without repeating contract call", async () => { + wireCreate(); + + const key = "test-idem-trade-key-999"; + + const res1 = await request(app) + .post("/api/v1/trades") + .set("Authorization", `Bearer ${sellerToken}`) + .set("Idempotency-Key", key) + .send({ assetType: "MTN_AIRTIME", amount: 500_000_000, expiresInHours: 24 }); + + expect(res1.status).toBe(201); + expect(mockCreateListing).toHaveBeenCalledTimes(1); + + // Second request with the same idempotency key — should return cached 201 without calling createListing + const res2 = await request(app) + .post("/api/v1/trades") + .set("Authorization", `Bearer ${sellerToken}`) + .set("Idempotency-Key", key) + .send({ assetType: "MTN_AIRTIME", amount: 500_000_000, expiresInHours: 24 }); + + expect(res2.status).toBe(201); + expect(res2.body).toEqual(res1.body); + expect(mockCreateListing).toHaveBeenCalledTimes(1); + }); }); diff --git a/server/src/routes/trades.ts b/server/src/routes/trades.ts index 6c331ce..f736a20 100644 --- a/server/src/routes/trades.ts +++ b/server/src/routes/trades.ts @@ -2,6 +2,7 @@ import { Router, type Response } from "express"; import { v4 as uuidv4 } from "uuid"; import { asStroops, fromStroops, toStroops } from "@airflex/shared/units"; import pool from "../db"; +import { cache } from "../services/cache"; import { authenticate, optionalAuthenticate, AuthenticatedRequest } from "../middleware/authenticate"; import { validate } from "../middleware/validate"; import { @@ -147,6 +148,23 @@ router.post( const { assetType, amount, expiresInHours } = req.body as CreateTradeInput; const { sub: sellerId, stellarPublicKey } = (req as unknown as AuthenticatedRequest).user; + const rawIdempotencyKey = req.header("idempotency-key"); + const idempotencyKey = rawIdempotencyKey ? rawIdempotencyKey.trim() : null; + const cacheKey = idempotencyKey ? `idempotency:trades:${sellerId}:${idempotencyKey}` : null; + + if (cacheKey) { + const cached = await cache.get(cacheKey); + if (cached) { + try { + const parsed = JSON.parse(cached) as { status: number; body: unknown }; + res.status(parsed.status).json(parsed.body); + return; + } catch { + // If parse fails, ignore corrupt cache entry and proceed + } + } + } + // KYC gate: a seller must be verified before they can list a trade. This // is checked here rather than only relying on the frontend, since the // frontend check can be bypassed by calling the API directly. @@ -215,7 +233,18 @@ router.post( [tradeId, sellerId, assetType, nairaAmount, contractListingId, expiresAt] ); - res.status(201).json({ data: rows[0] }); + const responseBody = { data: rows[0] }; + + if (cacheKey) { + // 24-hour TTL for idempotency key response + await cache.set( + cacheKey, + JSON.stringify({ status: 201, body: responseBody }), + 24 * 60 * 60 + ); + } + + res.status(201).json(responseBody); } ); diff --git a/server/src/services/cache.ts b/server/src/services/cache.ts index 0264790..6a575b4 100644 --- a/server/src/services/cache.ts +++ b/server/src/services/cache.ts @@ -24,52 +24,69 @@ import logger from "../utils/logger"; class RedisCacheClient { private socket: Socket | null = null; private connected = false; + private connectPromise: Promise | null = null; private queue: Array<{ resolve: (v: string | null) => void; reject: (e: Error) => void; }> = []; private buffer = ""; - connect(url: string): void { - try { - const parsed = new URL(url); - const host = parsed.hostname; - const port = parseInt(parsed.port || "6379", 10); - const pass = parsed.password || null; - - this.socket = new Socket(); - this.socket.setEncoding("utf8"); - - this.socket.connect(port, host, () => { - this.connected = true; - if (pass) { - this.socket!.write(`*2\r\n$4\r\nAUTH\r\n$${pass.length}\r\n${pass}\r\n`); - } - logger.info({ host, port }, "[cache] Redis connected"); - }); - - this.socket.on("data", (chunk: string) => { - this.buffer += chunk; - this.flushBuffer(); - }); - - this.socket.on("error", (err) => { - logger.warn({ err: err.message }, "[cache] Redis socket error — using in-memory cache"); - this.connected = false; - for (const p of this.queue) p.resolve(null); - this.queue = []; - }); - - this.socket.on("close", () => { - this.connected = false; - logger.warn("[cache] Redis connection closed"); - }); - } catch (err) { - logger.warn( - { err: (err as Error).message }, - "[cache] Redis connect failed — using in-memory cache" - ); + connect(url: string): Promise { + if (this.connectPromise) { + return this.connectPromise; } + + this.connectPromise = new Promise((resolve, reject) => { + try { + const parsed = new URL(url); + const host = parsed.hostname; + const port = parseInt(parsed.port || "6379", 10); + const pass = parsed.password || null; + + this.socket = new Socket(); + this.socket.setEncoding("utf8"); + + let settled = false; + + this.socket.connect(port, host, () => { + this.connected = true; + if (pass) { + this.socket!.write(`*2\r\n$4\r\nAUTH\r\n$${pass.length}\r\n${pass}\r\n`); + } + logger.info({ host, port }, "[cache] Redis connected"); + if (!settled) { + settled = true; + resolve(); + } + }); + + this.socket.on("data", (chunk: string) => { + this.buffer += chunk; + this.flushBuffer(); + }); + + this.socket.on("error", (err) => { + this.connected = false; + for (const p of this.queue) p.resolve(null); + this.queue = []; + if (!settled) { + settled = true; + reject(err); + } else { + logger.warn({ err: err.message }, "[cache] Redis socket error — using in-memory cache"); + } + }); + + this.socket.on("close", () => { + this.connected = false; + logger.warn("[cache] Redis connection closed"); + }); + } catch (err) { + reject(err as Error); + } + }); + + return this.connectPromise; } private flushBuffer(): void { @@ -79,8 +96,9 @@ class RedisCacheClient { if (first === "+") { const end = this.buffer.indexOf("\r\n"); if (end === -1) break; + const val = this.buffer.slice(1, end); this.buffer = this.buffer.slice(end + 2); - this.queue.shift()!.resolve("OK"); + this.queue.shift()!.resolve(val); } else if (first === ":") { const end = this.buffer.indexOf("\r\n"); @@ -130,6 +148,12 @@ class RedisCacheClient { }); } + /** PING — returns "PONG" or status from Redis */ + async ping(): Promise { + const cmd = "*1\r\n$4\r\nPING\r\n"; + return this.send(cmd); + } + /** GET key — returns the value or null on miss/error. */ async get(key: string): Promise { const cmd = `*2\r\n$3\r\nGET\r\n$${key.length}\r\n${key}\r\n`; @@ -145,6 +169,17 @@ class RedisCacheClient { return this.send(cmd); } + disconnect(): void { + if (this.socket) { + this.socket.destroy(); + this.socket = null; + } + this.connected = false; + this.connectPromise = null; + this.buffer = ""; + this.queue = []; + } + get isConnected(): boolean { return this.connected; } @@ -222,4 +257,42 @@ export const cache = { void this.set(key, serialised, ttlSeconds).catch(() => undefined); return value; }, + + /** + * Verifies that Redis is reachable and responsive. + * If REDIS_URL is set (or required in production), performs connect and PING. + * Throws an Error if Redis cannot be reached. + */ + async checkReady(timeoutMs = 5000): Promise { + const url = process.env["REDIS_URL"]; + if (!url) { + if (process.env["NODE_ENV"] === "production") { + throw new Error("REDIS_URL is required in production"); + } + return; + } + + const connectP = redis.connect(url); + const timeoutP = new Promise((_, reject) => { + const timer = setTimeout(() => { + reject(new Error(`Redis connection timed out after ${timeoutMs}ms`)); + }, timeoutMs); + timer.unref?.(); + }); + + await Promise.race([connectP, timeoutP]); + + const pong = await redis.ping(); + if (!pong) { + throw new Error("Redis ping returned empty response"); + } + }, + + disconnect(): void { + redis.disconnect(); + }, + + getClient(): RedisCacheClient { + return redis; + }, }; diff --git a/server/src/services/fraudDetection.redis.test.ts b/server/src/services/fraudDetection.redis.test.ts new file mode 100644 index 0000000..0119b2a --- /dev/null +++ b/server/src/services/fraudDetection.redis.test.ts @@ -0,0 +1,117 @@ +/** + * fraudDetection.redis.test.ts + * + * Integration test verifying that velocity counts persist across simulated + * process restarts using a Redis instance (issue #251). + */ + +import net from "net"; +import { FraudDetectionService } from "./fraudDetection"; +import { cache } from "./cache"; +import pool from "../db"; + +jest.mock("../db", () => ({ + query: jest.fn(), +})); + +describe("FraudDetectionService Redis persistence across process restart", () => { + let server: net.Server; + let port: number; + const store = new Map(); + + beforeAll((done) => { + // Spin up an in-process TCP server that speaks Redis RESP protocol + server = net.createServer((socket) => { + let buf = ""; + socket.on("data", (chunk) => { + buf += chunk.toString("utf8"); + while (buf.includes("\r\n")) { + if (buf.startsWith("*1\r\n$4\r\nPING\r\n")) { + socket.write("+PONG\r\n"); + buf = buf.slice("*1\r\n$4\r\nPING\r\n".length); + } else if (buf.includes("SET")) { + // Match RESP SET command: *5\r\n$3\r\nSET\r\n$\r\n\r\n$\r\n\r\n$2\r\nEX\r\n... + const lines = buf.split("\r\n"); + if (lines.length >= 7) { + const key = lines[4]; + const val = lines[6]; + if (key && val) { + store.set(key, val); + } + socket.write("+OK\r\n"); + buf = ""; + } else { + break; + } + } else if (buf.includes("GET")) { + // Match RESP GET command: *2\r\n$3\r\nGET\r\n$\r\n\r\n + const lines = buf.split("\r\n"); + if (lines.length >= 5) { + const key = lines[4]; + const val = key ? store.get(key) : undefined; + if (val !== undefined) { + socket.write(`$${Buffer.byteLength(val)}\r\n${val}\r\n`); + } else { + socket.write("$-1\r\n"); + } + buf = ""; + } else { + break; + } + } else { + break; + } + } + }); + }); + + server.listen(0, "127.0.0.1", () => { + const addr = server.address() as net.AddressInfo; + port = addr.port; + process.env["REDIS_URL"] = `redis://127.0.0.1:${port}`; + done(); + }); + }); + + afterAll((done) => { + cache.disconnect(); + server.close(done); + }); + + it("persists velocity count across cache client disconnection and reconnection (process restart)", async () => { + // Connect initial client + await cache.checkReady(); + + const userId = "persistent-user-456"; + const action = "trade_creation"; + + // 1st request before restart + const res1 = await FraudDetectionService.checkVelocity(userId, action); + expect(res1.allowed).toBe(true); + + // Verify key exists in the backing store with count 1 + const stored1 = store.get(`velocity:${action}:${userId}`); + expect(stored1).toBeDefined(); + expect(JSON.parse(stored1!).count).toBe(1); + + // 2nd request before restart + const res2 = await FraudDetectionService.checkVelocity(userId, action); + expect(res2.allowed).toBe(true); + const stored2 = store.get(`velocity:${action}:${userId}`); + expect(JSON.parse(stored2!).count).toBe(2); + + // --- SIMULATE PROCESS RESTART --- + // Disconnect cache client completely (simulating process exit and new boot) + cache.disconnect(); + + // Reconnect as a new process instance would on startup + await cache.checkReady(); + + // 3rd request after restart: should pick up existing count (2) from Redis and increment to 3 + const res3 = await FraudDetectionService.checkVelocity(userId, action); + expect(res3.allowed).toBe(true); + + const stored3 = store.get(`velocity:${action}:${userId}`); + expect(JSON.parse(stored3!).count).toBe(3); + }); +}); diff --git a/server/src/utils/logger.ts b/server/src/utils/logger.ts index 3564289..c6b6bb9 100644 --- a/server/src/utils/logger.ts +++ b/server/src/utils/logger.ts @@ -6,6 +6,7 @@ type LogMethod = { }; const levelColors: Record = { + FATAL: "\u001b[35m", INFO: "\u001b[36m", WARN: "\u001b[33m", ERROR: "\u001b[31m", @@ -65,6 +66,7 @@ export const logger = { info: createLogMethod("info"), warn: createLogMethod("warn"), error: createLogMethod("error"), + fatal: createLogMethod("fatal"), debug: createLogMethod("debug"), }; diff --git a/server/src/utils/phone.ts b/server/src/utils/phone.ts index 02a4c87..a23751a 100644 --- a/server/src/utils/phone.ts +++ b/server/src/utils/phone.ts @@ -51,3 +51,13 @@ export function normalizePhone(phone: string): string { return trimmed; } + +/** + * Mask a phone number — show country code + last 4 digits only: +234 *** *** 7890 + */ +export function maskPhone(phone: string): string { + if (phone.length < 6) return "***"; + const visible = phone.slice(-4); + const prefix = phone.startsWith("+") ? phone.slice(0, 4) : phone.slice(0, 3); + return `${prefix} *** *** ${visible}`; +}