Skip to content
Merged
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
4 changes: 2 additions & 2 deletions backend/src/__tests__/integration/loanDisputeFlow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@

let request: typeof import('supertest');
let jwt: typeof import('jsonwebtoken');
let app: any;

Check warning on line 34 in backend/src/__tests__/integration/loanDisputeFlow.test.ts

View workflow job for this annotation

GitHub Actions / backend

Unexpected any. Specify a different type

const TEST_PUBLIC_KEY = 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN';
const ADMIN_API_KEY = 'test-api-key';
Expand Down Expand Up @@ -108,7 +108,7 @@

mockQuery
.mockResolvedValueOnce(dbRows([{ address: TEST_PUBLIC_KEY }]))
.mockResolvedValueOnce(dbRows([{ loan_id: LOAN_ID }]))
.mockResolvedValueOnce(dbRows([{ loan_id: LOAN_ID, address: TEST_PUBLIC_KEY }]))
.mockResolvedValueOnce(dbRows([{ id: DISPUTE_ID }]))
.mockResolvedValueOnce(dbOk());

Expand Down Expand Up @@ -193,7 +193,7 @@

mockQuery
.mockResolvedValueOnce(dbRows([{ address: TEST_PUBLIC_KEY }]))
.mockResolvedValueOnce(dbRows([{ loan_id: LOAN_ID }]))
.mockResolvedValueOnce(dbRows([{ loan_id: LOAN_ID, address: TEST_PUBLIC_KEY }]))
.mockResolvedValueOnce(dbRows([{ id: DISPUTE_ID }]))
.mockResolvedValueOnce(dbOk());

Expand Down
26 changes: 24 additions & 2 deletions backend/src/__tests__/loanDispute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

let request: typeof import('supertest');
let jwt: typeof import('jsonwebtoken');
let app: any;

Check warning on line 23 in backend/src/__tests__/loanDispute.test.ts

View workflow job for this annotation

GitHub Actions / backend

Unexpected any. Specify a different type
// Dynamic imports after mocks
beforeAll(async () => {
({ default: request } = await import('supertest'));
Expand Down Expand Up @@ -133,13 +133,13 @@
* [1] SELECT address FROM contract_events WHERE loan_id = 42 → TEST_PUBLIC_KEY ✓
*
* contestDefault:
* [2] SELECT contract_events WHERE event_type='LoanDefaulted' → found
* [2] SELECT contract_events WHERE event_type='LoanDefaulted' → found (address = borrower)
* [3] INSERT loan_disputes RETURNING id → disputeId
* [4] INSERT contract_events LoanDisputed
*/
mockQuery
.mockResolvedValueOnce(dbRows([{ address: TEST_PUBLIC_KEY }])) // [1] loanAccess
.mockResolvedValueOnce(dbRows([{ loan_id: LOAN_ID }])) // [2] defaulted check
.mockResolvedValueOnce(dbRows([{ loan_id: LOAN_ID, address: TEST_PUBLIC_KEY }])) // [2] defaulted + ownership check
.mockResolvedValueOnce(dbRows([{ id: DISPUTE_ID }])) // [3] dispute INSERT
.mockResolvedValueOnce(dbOk()); // [4] LoanDisputed event

Expand All @@ -154,6 +154,28 @@
disputeId = res.body.disputeId ?? disputeId;
});

it('should reject a contest when the defaulted loan belongs to another borrower', async () => {
/**
* POST /api/loans/42/contest-default
* requireLoanOwner:
* [1] SELECT address FROM loan_events WHERE loan_id = 42 → TEST_PUBLIC_KEY (middleware passes)
*
* contestDefault:
* [2] SELECT contract_events WHERE event_type='LoanDefaulted' → address = OTHER borrower → 403
*/
const OTHER_PUBLIC_KEY = 'GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H';
mockQuery
.mockResolvedValueOnce(dbRows([{ address: TEST_PUBLIC_KEY }])) // [1] loanOwner
.mockResolvedValueOnce(dbRows([{ loan_id: LOAN_ID, address: OTHER_PUBLIC_KEY }])); // [2] defaulted event owned by another borrower

const res = await request(app)
.post(`/api/loans/${defaultedLoanId}/contest-default`)
.set('Authorization', `Bearer ${authToken}`)
.send({ reason: 'Attempting to hijack another borrower default.' });

expect(res.status).toBe(403);
});

it('should freeze penalty accrual during dispute', async () => {
/**
* GET /api/loans/42
Expand Down
11 changes: 10 additions & 1 deletion backend/src/controllers/loanController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
try {
const { loanId } = req.params;

const borrower = (req as any).user?.publicKey as string;

Check warning on line 57 in backend/src/controllers/loanController.ts

View workflow job for this annotation

GitHub Actions / backend

Unexpected any. Specify a different type

const result = await query('SELECT * FROM loans WHERE id = $1', [loanId]);
const loan = result.rows[0] as Record<string, unknown> | undefined;
Expand Down Expand Up @@ -164,13 +164,22 @@

// Check loan exists and is defaulted
const loanResult = await query(
`SELECT loan_id FROM contract_events WHERE loan_id = $1 AND event_type = 'LoanDefaulted' LIMIT 1`,
`SELECT address FROM contract_events WHERE loan_id = $1 AND event_type = 'LoanDefaulted' LIMIT 1`,
[loanId],
);
if (loanResult.rows.length === 0) {
throw AppError.badRequest('Loan is not defaulted or does not exist');
}

// Ownership check (BOLA/IDOR): the `address` on the LoanDefaulted event is
// the loan's borrower, so only that wallet may contest the default. The
// route also runs `requireLoanOwner`; keeping the assertion here makes the
// handler safe wherever it is mounted.
const loanBorrower = loanResult.rows[0].address as string | undefined;
if (loanBorrower !== borrower) {
throw AppError.forbidden('You are not the borrower of this loan');
}

// Insert dispute record and return disputeId
const disputeResult = await query(
`INSERT INTO loan_disputes (loan_id, borrower, reason, status) VALUES ($1, $2, $3, 'open') RETURNING id`,
Expand Down
Loading