diff --git a/backend/src/__tests__/loanConfig.test.ts b/backend/src/__tests__/loanConfig.test.ts index a451ecc9..b32c78ce 100644 --- a/backend/src/__tests__/loanConfig.test.ts +++ b/backend/src/__tests__/loanConfig.test.ts @@ -1,4 +1,4 @@ -import { validateLoanConfig } from '../config/loanConfig.js'; +import { validateLoanConfig, validateLoanConfigOnStartup } from '../config/loanConfig.js'; describe('Loan config startup validation', () => { const originalEnv = { @@ -60,3 +60,66 @@ describe('Loan config startup validation', () => { expect(() => validateLoanConfig()).toThrow('LOAN_INTEREST_RATE_PERCENT must be a valid number'); }); }); + +describe('validateLoanConfigOnStartup', () => { + const originalEnv = { + LOAN_MIN_SCORE: process.env.LOAN_MIN_SCORE, + LOAN_MAX_AMOUNT: process.env.LOAN_MAX_AMOUNT, + LOAN_INTEREST_RATE_PERCENT: process.env.LOAN_INTEREST_RATE_PERCENT, + CREDIT_SCORE_THRESHOLD: process.env.CREDIT_SCORE_THRESHOLD, + }; + + let processExitSpy: jest.SpyInstance; + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + processExitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((_code?: string | number | null) => undefined as never); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + process.env.LOAN_MIN_SCORE = originalEnv.LOAN_MIN_SCORE; + process.env.LOAN_MAX_AMOUNT = originalEnv.LOAN_MAX_AMOUNT; + process.env.LOAN_INTEREST_RATE_PERCENT = originalEnv.LOAN_INTEREST_RATE_PERCENT; + process.env.CREDIT_SCORE_THRESHOLD = originalEnv.CREDIT_SCORE_THRESHOLD; + processExitSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + it('succeeds silently when all loan config vars are valid', () => { + process.env.LOAN_MIN_SCORE = '500'; + process.env.LOAN_MAX_AMOUNT = '100000'; + process.env.LOAN_INTEREST_RATE_PERCENT = '15'; + process.env.CREDIT_SCORE_THRESHOLD = '650'; + + expect(() => validateLoanConfigOnStartup()).not.toThrow(); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('calls process.exit(1) and logs when a required var is missing', () => { + delete process.env.LOAN_MIN_SCORE; + process.env.LOAN_MAX_AMOUNT = '100000'; + process.env.LOAN_INTEREST_RATE_PERCENT = '15'; + process.env.CREDIT_SCORE_THRESHOLD = '650'; + + validateLoanConfigOnStartup(); + + expect(processExitSpy).toHaveBeenCalledWith(1); + expect(consoleErrorSpy).toHaveBeenCalled(); + const logged: string = consoleErrorSpy.mock.calls[0][0] as string; + expect(logged).toMatch(/LOAN_MIN_SCORE is required/i); + }); + + it('calls process.exit(1) when a value is out of range', () => { + process.env.LOAN_MIN_SCORE = '0'; // below min 300 + process.env.LOAN_MAX_AMOUNT = '100000'; + process.env.LOAN_INTEREST_RATE_PERCENT = '15'; + process.env.CREDIT_SCORE_THRESHOLD = '650'; + + validateLoanConfigOnStartup(); + + expect(processExitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/backend/src/config/loanConfig.ts b/backend/src/config/loanConfig.ts index c7e948be..26b1c755 100644 --- a/backend/src/config/loanConfig.ts +++ b/backend/src/config/loanConfig.ts @@ -82,3 +82,25 @@ export function validateLoanConfig(): LoanConfig { const loanConfig = getLoanConfig(); return loanConfig; } + +/** + * Startup guard — mirrors validateEnvVars(). + * Calls getLoanConfig() and, on any validation failure, logs the error and + * terminates the process immediately so the server never starts with bad loan + * parameters. + */ +export function validateLoanConfigOnStartup(): void { + try { + getLoanConfig(); + } catch (err) { + const boldRed = (msg: string) => `\x1b[1;31m${msg}\x1b[0m`; + const bold = (msg: string) => `\x1b[1m${msg}\x1b[0m`; + + const errorPrefix = boldRed('FATAL ERROR: Loan config validation failed'); + const detail = bold(err instanceof Error ? err.message : String(err)); + const actionMsg = `Please verify the loan config variables in your \x1b[4m.env\x1b[0m file.`; + + console.error(`\n${errorPrefix}\n${detail}\n${actionMsg}\n`); + process.exit(1); + } +} diff --git a/backend/src/config/swaggerSchemas.ts b/backend/src/config/swaggerSchemas.ts index 1d10df1e..dcb9f0c4 100644 --- a/backend/src/config/swaggerSchemas.ts +++ b/backend/src/config/swaggerSchemas.ts @@ -505,6 +505,10 @@ export const swaggerSchemas = { required: ['eventId', 'eventType', 'borrower', 'ledger', 'ledgerClosedAt', 'txHash'], }, Pagination: { + description: + 'Cursor-based pagination envelope. See the ' + + '[pagination contract](../../../docs/pagination-contract.md) for the full ' + + 'contract: offset semantics, maximum limit cap, and total count behaviour.', type: 'object', properties: { total: { type: 'integer' }, diff --git a/backend/src/index.ts b/backend/src/index.ts index 1226ca96..a7794d63 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -34,7 +34,7 @@ import { stopCrossContractReconciler, } from './services/crossContractReconciler.js'; import { sorobanService } from './services/sorobanService.js'; -import { validateLoanConfig } from './config/loanConfig.js'; +import { validateLoanConfigOnStartup } from './config/loanConfig.js'; import { startLoanDueCheckCron, stopLoanDueCheckCron } from './cron/loanCheckCron.js'; // Imported the score decay scheduler initialization wrapper import { startScoreDecayScheduler } from './cron/scoreDecayJob.js'; @@ -45,9 +45,11 @@ const port = process.env.PORT || 3001; // Maintain a mutable handle to invoke clean scheduler closures on process stops let scoreDecaySchedulerHandle: { stop: () => void } | null = null; -// Validate score delta and loan config on startup before accepting traffic +// Validate loan config on startup before accepting traffic +validateLoanConfigOnStartup(); + +// Validate score delta config on startup before accepting traffic try { - validateLoanConfig(); sorobanService.validateScoreConfig(); } catch (err) { logger.error('Startup configuration is invalid, aborting startup.', { err }); diff --git a/frontend/src/app/components/ui.tsx b/frontend/src/app/components/ui.tsx index b5073ed4..489fdddd 100644 --- a/frontend/src/app/components/ui.tsx +++ b/frontend/src/app/components/ui.tsx @@ -1 +1 @@ -export const COPY_FEEDBACK_RESET_MS = 2000; +export { COPY_FEEDBACK_RESET_MS } from './ui/CopyButton'; diff --git a/frontend/src/app/components/ui/CopyButton.tsx b/frontend/src/app/components/ui/CopyButton.tsx index 0368cfd6..6521e2c9 100644 --- a/frontend/src/app/components/ui/CopyButton.tsx +++ b/frontend/src/app/components/ui/CopyButton.tsx @@ -7,13 +7,15 @@ interface CopyButtonProps { value: string; } +export const COPY_FEEDBACK_RESET_MS = 2000; + export function CopyButton({ value }: CopyButtonProps) { const [copied, setCopied] = useState(false); const handleCopy = () => { navigator.clipboard.writeText(value).then(() => { setCopied(true); - setTimeout(() => setCopied(false), 2000); + setTimeout(() => setCopied(false), COPY_FEEDBACK_RESET_MS); }); }; diff --git a/frontend/src/app/hooks/useReveal.test.tsx b/frontend/src/app/hooks/useReveal.test.tsx new file mode 100644 index 00000000..3feac3ab --- /dev/null +++ b/frontend/src/app/hooks/useReveal.test.tsx @@ -0,0 +1,157 @@ +/** + * hooks/useReveal.test.tsx + * + * Unit tests for useReveal (#1518): + * – success path: revealed value is stored and accessible + * – expired / access-denied path: error is surfaced, no value leaked + * – PII leak prevention: clearRevealed() nulls the value and resets state + */ + +import { renderHook, act, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { useReveal } from "./useReveal"; + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false } }, + }); + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe("useReveal", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + it("stores the revealed value on a successful response", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ value: "test@example.com" }), + }) as unknown as typeof fetch; + + const { result } = renderHook(() => useReveal(), { wrapper: createWrapper() }); + + act(() => { + result.current.reveal({ + recipientId: "r-1", + field: "email", + reason: "audit", + }); + }); + + await waitFor(() => expect(result.current.isPending).toBe(false)); + + expect(result.current.revealedValue).toBe("test@example.com"); + expect(result.current.error).toBeNull(); + }); + + it("surfaces an error and does not leak PII when the server returns 403 (access denied)", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 403, + statusText: "Forbidden", + }) as unknown as typeof fetch; + + const { result } = renderHook(() => useReveal(), { wrapper: createWrapper() }); + + act(() => { + result.current.reveal({ + recipientId: "r-2", + field: "phone", + reason: "audit", + }); + }); + + await waitFor(() => expect(result.current.error).not.toBeNull()); + + expect(result.current.revealedValue).toBeNull(); + expect(result.current.error?.message).toMatch(/Reveal failed/i); + }); + + it("surfaces an error and does not leak PII when the server returns 410 (reveal expired)", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 410, + statusText: "Gone", + }) as unknown as typeof fetch; + + const { result } = renderHook(() => useReveal(), { wrapper: createWrapper() }); + + act(() => { + result.current.reveal({ + recipientId: "r-3", + field: "name", + reason: "compliance", + }); + }); + + await waitFor(() => expect(result.current.error).not.toBeNull()); + + expect(result.current.revealedValue).toBeNull(); + }); + + it("clears revealedValue and resets mutation state via clearRevealed()", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ value: "John Doe" }), + }) as unknown as typeof fetch; + + const { result } = renderHook(() => useReveal(), { wrapper: createWrapper() }); + + act(() => { + result.current.reveal({ + recipientId: "r-4", + field: "name", + reason: "audit", + }); + }); + + await waitFor(() => expect(result.current.revealedValue).toBe("John Doe")); + + act(() => { + result.current.clearRevealed(); + }); + + expect(result.current.revealedValue).toBeNull(); + expect(result.current.isPending).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it("does not expose the value before the request resolves", async () => { + let resolveFetch!: (val: unknown) => void; + global.fetch = jest.fn( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ) as unknown as typeof fetch; + + const { result } = renderHook(() => useReveal(), { wrapper: createWrapper() }); + + act(() => { + result.current.reveal({ + recipientId: "r-5", + field: "email", + reason: "audit", + }); + }); + + // While in-flight: no PII exposed + expect(result.current.revealedValue).toBeNull(); + + act(() => { + resolveFetch({ + ok: true, + json: async () => ({ value: "hidden@example.com" }), + }); + }); + + await waitFor(() => expect(result.current.revealedValue).toBe("hidden@example.com")); + }); +});