From c4dbfb0b0a37546de970c945c3ad798a4ef73b1d Mon Sep 17 00:00:00 2001 From: Warisu Date: Sun, 28 Jun 2026 13:57:59 +0100 Subject: [PATCH 1/2] fix(logging): lower production log baseline to capture http access logs (#1207) --- .../__tests__/requestLogger.spec.ts | 69 +++++++++++++++++++ backend/src/middleware/requestLogger.ts | 4 +- backend/src/utils/logger.ts | 7 +- 3 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 backend/src/middleware/__tests__/requestLogger.spec.ts diff --git a/backend/src/middleware/__tests__/requestLogger.spec.ts b/backend/src/middleware/__tests__/requestLogger.spec.ts new file mode 100644 index 00000000..d815d6a1 --- /dev/null +++ b/backend/src/middleware/__tests__/requestLogger.spec.ts @@ -0,0 +1,69 @@ +import type { Request, Response, NextFunction } from "express"; +import { requestLogger } from "../requestLogger.js"; +import logger from "../../utils/logger.js"; + +describe("Request Logger Production Access Test Harness (#1207)", () => { + let writeSpy: jest.SpyInstance; + const originalNodeEnv = process.env.NODE_ENV; + + const setNodeEnv = (value: string | undefined) => { + Object.defineProperty(process.env, "NODE_ENV", { + value, + configurable: true, + writable: true, + }); + }; + + beforeEach(() => { + writeSpy = jest.spyOn(logger, "write").mockImplementation(() => true); + }); + + afterEach(() => { + setNodeEnv(originalNodeEnv); + // Restore logger level dynamically based on initial state + logger.level = process.env.NODE_ENV === "development" ? "debug" : "http"; + writeSpy.mockRestore(); + }); + + it("should output 200 OK access trace entries cleanly when running under a production profile configuration", () => { + setNodeEnv("production"); + logger.level = "http"; // Explicitly match updated target runtime calculation + + const mockReq = { + method: "GET", + originalUrl: "/api/v1/loans", + ip: "10.0.0.1", + get: (header: string) => (header === "user-agent" ? "Jest-Test-Agent" : undefined), + } as unknown as Request; + + let finishCallback: () => void = () => {}; + const mockRes = { + statusCode: 200, + on: (event: string, callback: () => void) => { + if (event === "finish") finishCallback = callback; + }, + } as unknown as Response; + + const mockNext = jest.fn() as NextFunction; + + requestLogger(mockReq, mockRes, mockNext); + finishCallback(); + + expect(mockNext).toHaveBeenCalled(); + expect(writeSpy).toHaveBeenCalledWith( + expect.objectContaining({ + level: "http", + message: "HTTP request", + statusCode: 200, + url: "/api/v1/loans", + method: "GET", + }) + ); + }); + + it("should confirm development profile logging remains at debug priority", () => { + setNodeEnv("development"); + logger.level = "debug"; + expect(logger.level).toBe("debug"); + }); +}); \ No newline at end of file diff --git a/backend/src/middleware/requestLogger.ts b/backend/src/middleware/requestLogger.ts index 22f7fe8f..78128807 100644 --- a/backend/src/middleware/requestLogger.ts +++ b/backend/src/middleware/requestLogger.ts @@ -19,7 +19,7 @@ export const requestLogger = ( const { statusCode } = res; const payload = { - requestId: req.requestId, + requestId: (req as any).requestId, // Safely handles custom middleware assignment method, url: originalUrl, statusCode, @@ -38,4 +38,4 @@ export const requestLogger = ( }); next(); -}; +}; \ No newline at end of file diff --git a/backend/src/utils/logger.ts b/backend/src/utils/logger.ts index 489cd1ad..004d5d4a 100644 --- a/backend/src/utils/logger.ts +++ b/backend/src/utils/logger.ts @@ -13,7 +13,8 @@ const validLevels = Object.keys(levels); const defaultLevelForEnv = () => { const env = process.env.NODE_ENV || "development"; - return env === "development" ? "debug" : "info"; + // Changed from "info" to "http" so priority 3 (http) logs pass in staging/production + return env === "development" ? "debug" : "http"; }; const level = () => { @@ -100,9 +101,11 @@ const withContext = (context: LogContext = {}) => { logger.warn(message, { ...baseMeta, ...meta }), error: (message: string, meta?: any) => logger.error(message, { ...baseMeta, ...meta }), + http: (message: string, meta?: any) => + logger.http(message, { ...baseMeta, ...meta }), }; }; const loggerWithContext = Object.assign(logger, { withContext }); -export default loggerWithContext; +export default loggerWithContext; \ No newline at end of file From 435edfa38ed679fff1ff277f5bedfc360c06a3b5 Mon Sep 17 00:00:00 2001 From: Warisu Date: Sun, 28 Jun 2026 14:05:19 +0100 Subject: [PATCH 2/2] migration: backfill and add CHECK constraint for score range bounds (#1197) --- .../1771691269865_initial-schema.js | 25 +++++++++++++++++++ backend/src/services/scoresService.ts | 13 ++++++---- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/backend/migrations/1771691269865_initial-schema.js b/backend/migrations/1771691269865_initial-schema.js index 859bbb92..d7a7c92e 100644 --- a/backend/migrations/1771691269865_initial-schema.js +++ b/backend/migrations/1771691269865_initial-schema.js @@ -45,3 +45,28 @@ export const down = (pgm) => { pgm.dropTable("remittance_history"); pgm.dropTable("scores"); }; + + +exports.up = async (pgm) => { + // 1. Data Backfill: Safely clamp any legacy database rows before applying the constraint + await pgm.sql(` + UPDATE scores + SET score = LEAST(850, GREATEST(300, score)) + WHERE score < 300 OR score > 850; + `); + + // 2. Schema Hardening: Introduce the strict CHECK constraint to block invalid manual updates + await pgm.sql(` + ALTER TABLE scores + ADD CONSTRAINT chk_score_range + CHECK (score BETWEEN 300 AND 850); + `); +}; + +exports.down = async (pgm) => { + // Drop constraint cleanly if a rollback is triggered + await pgm.sql(` + ALTER TABLE scores + DROP CONSTRAINT IF EXISTS chk_score_range; + `); +}; \ No newline at end of file diff --git a/backend/src/services/scoresService.ts b/backend/src/services/scoresService.ts index 3f32d788..b6b3edc4 100644 --- a/backend/src/services/scoresService.ts +++ b/backend/src/services/scoresService.ts @@ -9,7 +9,7 @@ import logger from "../utils/logger.js"; * All rows are upserted in a single query for efficiency. * * When `client` is supplied the query runs on that pinned connection so it - * participates in the caller's open transaction. When omitted the shared + * participates in the caller's open transaction. When omitted the shared * pool `query()` is used (standalone use). */ export async function updateUserScoresBulk( @@ -30,9 +30,10 @@ export async function updateUserScoresBulk( if (params.length === 0) return; + // Clamped the initial raw value payload insertion step to prevent violating constraints on initial inserts const valuePlaceholders = Array.from( { length: params.length / 2 }, - (_, i) => `($${i * 2 + 1}, 500 + $${i * 2 + 2})`, + (_, i) => `($${i * 2 + 1}, LEAST(850, GREATEST(300, 500 + $${i * 2 + 2})))`, ).join(", "); const sql = ` @@ -88,15 +89,17 @@ export async function setAbsoluteUserScoresBulk( if (valuePlaceholders.length === 0) return; + // Added explicit application-level LEAST/GREATEST clamping on selection and overwrite paths + // to ensure out-of-bounds calculations from external sources never trigger CHECK runtime failures. const sql = ` WITH reconciled_scores (user_id, current_score) AS ( VALUES ${valuePlaceholders.join(",")} ) INSERT INTO scores (user_id, current_score) - SELECT user_id, current_score FROM reconciled_scores + SELECT user_id, LEAST(850, GREATEST(300, current_score)) FROM reconciled_scores ON CONFLICT (user_id) DO UPDATE SET - current_score = EXCLUDED.current_score, + current_score = LEAST(850, GREATEST(300, EXCLUDED.current_score)), updated_at = CURRENT_TIMESTAMP `; @@ -123,4 +126,4 @@ export async function setAbsoluteUserScoresBulk( }); throw error; } -} +} \ No newline at end of file