Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions backend/migrations/1771691269865_initial-schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
`);
};
69 changes: 69 additions & 0 deletions backend/src/middleware/__tests__/requestLogger.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
4 changes: 2 additions & 2 deletions backend/src/middleware/requestLogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
const { statusCode } = res;

const payload = {
requestId: req.requestId,
requestId: (req as any).requestId, // Safely handles custom middleware assignment

Check warning on line 22 in backend/src/middleware/requestLogger.ts

View workflow job for this annotation

GitHub Actions / backend

Unexpected any. Specify a different type
method,
url: originalUrl,
statusCode,
Expand All @@ -38,4 +38,4 @@
});

next();
};
};
12 changes: 8 additions & 4 deletions backend/src/services/scoresService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -30,8 +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}, LEAST(850, GREATEST(300, 500 + $${i * 2 + 2})))`,
(_, i) =>
`($${i * 2 + 1}, LEAST(850, GREATEST(300, 500 + $${i * 2 + 2})))`,
).join(", ");
Expand Down Expand Up @@ -89,15 +91,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
`;

Expand All @@ -124,4 +128,4 @@ export async function setAbsoluteUserScoresBulk(
});
throw error;
}
}
}
7 changes: 5 additions & 2 deletions backend/src/utils/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -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;
Loading