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
65 changes: 64 additions & 1 deletion backend/src/__tests__/loanConfig.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { validateLoanConfig } from '../config/loanConfig.js';
import { validateLoanConfig, validateLoanConfigOnStartup } from '../config/loanConfig.js';

describe('Loan config startup validation', () => {
const originalEnv = {
Expand Down Expand Up @@ -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);
});
});
22 changes: 22 additions & 0 deletions backend/src/config/loanConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
4 changes: 4 additions & 0 deletions backend/src/config/swaggerSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
8 changes: 5 additions & 3 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 });
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/components/ui.tsx
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const COPY_FEEDBACK_RESET_MS = 2000;
export { COPY_FEEDBACK_RESET_MS } from './ui/CopyButton';
4 changes: 3 additions & 1 deletion frontend/src/app/components/ui/CopyButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
};

Expand Down
157 changes: 157 additions & 0 deletions frontend/src/app/hooks/useReveal.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
}

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"));
});
});
Loading