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
30 changes: 0 additions & 30 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

186 changes: 186 additions & 0 deletions backend/src/__tests__/remittanceController.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { jest } from '@jest/globals';
import type { NextFunction, Request, Response } from 'express';
import type { Remittance } from '../services/remittanceService.js';

const mockGetRemittance = jest.fn<(id: string) => Promise<Remittance>>();
const mockUpdateRemittanceStatus = jest.fn();
const mockSubmitSignedTx = jest.fn();

jest.unstable_mockModule('../services/remittanceService.js', () => ({
remittanceService: {
getRemittance: mockGetRemittance,
updateRemittanceStatus: mockUpdateRemittanceStatus,
},
}));

jest.unstable_mockModule('../services/sorobanService.js', () => ({
sorobanService: {
submitSignedTx: mockSubmitSignedTx,
},
}));

jest.unstable_mockModule('../services/notificationService.js', () => ({
notificationService: {
createNotification: jest.fn(),
},
}));

const { getRemittance, submitRemittanceTransaction } =
await import('../controllers/remittanceController.js');
const flushAsync = async (): Promise<void> => new Promise((resolve) => setImmediate(resolve));

const createMockResponse = (): Response => {
const res = {} as Response;
res.status = jest.fn().mockReturnValue(res) as unknown as Response['status'];
res.json = jest.fn().mockReturnValue(res) as unknown as Response['json'];
return res;
};

describe('remittanceController.getRemittance', () => {
const SENDER = 'GBTESTSENDER1234567890123456789012345678901234567890123';
const RECIPIENT = 'GBTESTRECIPIENT1234567890123456789012345678901234567890';
const OTHER_USER = 'GBTESTOTHERUSER12345678901234567890123456789012345678';

const mockRemittance: Remittance = {
id: 'remit-123',
senderId: SENDER,
recipientAddress: RECIPIENT,
amount: 100,
fromCurrency: 'USDC',
toCurrency: 'USDC',
status: 'pending',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};

beforeEach(() => {
jest.clearAllMocks();
});

it('allows sender to read their remittance', async () => {
mockGetRemittance.mockResolvedValue(mockRemittance);

const req = {
params: { id: 'remit-123' },
user: { publicKey: SENDER },
} as unknown as Request;
const res = createMockResponse();
const next = jest.fn();

getRemittance(req, res, next as unknown as NextFunction);
await flushAsync();

expect(mockGetRemittance).toHaveBeenCalledWith('remit-123');
expect(res.json).toHaveBeenCalledWith({
success: true,
data: mockRemittance,
});
expect(next).not.toHaveBeenCalled();
});

it('allows recipient to read the remittance', async () => {
mockGetRemittance.mockResolvedValue(mockRemittance);

const req = {
params: { id: 'remit-123' },
user: { publicKey: RECIPIENT },
} as unknown as Request;
const res = createMockResponse();
const next = jest.fn();

getRemittance(req, res, next as unknown as NextFunction);
await flushAsync();

expect(mockGetRemittance).toHaveBeenCalledWith('remit-123');
expect(res.json).toHaveBeenCalledWith({
success: true,
data: mockRemittance,
});
expect(next).not.toHaveBeenCalled();
});

it('denies access to an unauthorized third party (IDOR prevention)', async () => {
mockGetRemittance.mockResolvedValue(mockRemittance);

const req = {
params: { id: 'remit-123' },
user: { publicKey: OTHER_USER },
} as unknown as Request;
const res = createMockResponse();
const next = jest.fn();

getRemittance(req, res, next as unknown as NextFunction);
await flushAsync();

expect(mockGetRemittance).toHaveBeenCalledWith('remit-123');
expect(next).toHaveBeenCalledWith(
expect.objectContaining({
statusCode: 403,
message: 'You do not have access to this remittance',
}),
);
expect(res.json).not.toHaveBeenCalled();
});

it('throws unauthorized if user is not authenticated', async () => {
const req = {
params: { id: 'remit-123' },
} as unknown as Request;
const res = createMockResponse();
const next = jest.fn();

getRemittance(req, res, next as unknown as NextFunction);
await flushAsync();

expect(next).toHaveBeenCalledWith(
expect.objectContaining({
statusCode: 401,
message: 'Wallet address not found in request',
}),
);
});
});

describe('remittanceController.submitRemittanceTransaction', () => {
const SENDER = 'GBTESTSENDER1234567890123456789012345678901234567890123';
const OTHER_USER = 'GBTESTOTHERUSER12345678901234567890123456789012345678';

const mockRemittance: Remittance = {
id: 'remit-123',
senderId: SENDER,
recipientAddress: 'GBTESTRECIPIENT1234567890123456789012345678901234567890',
amount: 100,
fromCurrency: 'USDC',
toCurrency: 'USDC',
status: 'pending',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};

beforeEach(() => {
jest.clearAllMocks();
});

it('denies transaction submission from non-sender', async () => {
mockGetRemittance.mockResolvedValue(mockRemittance);

const req = {
params: { id: 'remit-123' },
body: { signedXdr: 'mock-signed-xdr' },
user: { publicKey: OTHER_USER },
} as unknown as Request;
const res = createMockResponse();
const next = jest.fn();

submitRemittanceTransaction(req, res, next as unknown as NextFunction);
await flushAsync();

expect(mockGetRemittance).toHaveBeenCalledWith('remit-123');
expect(next).toHaveBeenCalledWith(
expect.objectContaining({
statusCode: 403,
message: 'You do not have access to this remittance',
}),
);
});
});
5 changes: 1 addition & 4 deletions backend/src/__tests__/security-issues.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeAll, vi } from '@jest/globals';
import { describe, it, expect, beforeAll } from '@jest/globals';
import { Keypair } from '@stellar/stellar-sdk';
import * as authService from '../services/authService.js';
import { resolveRoleForWallet } from '../auth/rbac.js';
Expand All @@ -15,8 +15,6 @@ describe('Security Issues - Critical Fixes', () => {
// ─────────────────────────────────────────────────────────────────────────
describe('Issue #1359: JWT Expiration Validation', () => {
it('should reject expired tokens', () => {
const keypair = Keypair.random();

// Create a token that's already expired
const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MDAwMDAwMDB9.invalid';

Expand All @@ -40,7 +38,6 @@ describe('Security Issues - Critical Fixes', () => {
it('should enforce expiration claim during verification', () => {
// This test verifies that ignoreExpiration is NOT set to true
// by checking that the JWT library enforces exp claim
const secret = process.env.JWT_SECRET!;
const token = authService.generateJwtToken(Keypair.random().publicKey());

// The verifyJwtToken should respect the exp claim
Expand Down
2 changes: 1 addition & 1 deletion backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { errorHandler } from './middleware/errorHandler.js';
import { metricsHandler, metricsMiddleware } from './middleware/metrics.js';
import { requestLogger } from './middleware/requestLogger.js';
import { requestIdMiddleware } from './middleware/requestId.js';
import { pauseGuard, initializePauseState } from './middleware/pauseGuard.js';
import { pauseGuard } from './middleware/pauseGuard.js';
import { asyncHandler } from './utils/asyncHandler.js';
import { AppError } from './errors/AppError.js';
const app = express();
Expand Down
16 changes: 0 additions & 16 deletions backend/src/controllers/indexerController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,12 @@ import type { Request, Response } from 'express';
import { xdr } from '@stellar/stellar-sdk';
import { query } from '../db/connection.js';
import { EventIndexer, type SorobanRawEvent } from '../services/eventIndexer.js';
import { cacheService } from '../services/cacheService.js';
import {
SUPPORTED_WEBHOOK_EVENT_TYPES,
webhookService,
type WebhookEventType,
} from '../services/webhookService.js';
import {
buildKeysetClause,
createCursorPaginatedResponse,
decodeCursor,
encodeCursor,
Expand Down Expand Up @@ -85,20 +83,6 @@ const buildEventFilters = (req: Request, baseParams: unknown[], initialWhereClau
return { params, whereClause };
};

const buildEventsCacheKey = (scope: string, resourceId: string | number, req: Request) =>
[
'events',
scope,
String(resourceId),
`limit:${req.query.limit ?? 'default'}`,
`cursor:${req.query.cursor ?? 'default'}`,
`offset:${req.query.offset ?? 'default'}`,
`sort:${req.query.sort ?? 'default'}`,
`status:${req.query.status ?? req.query.eventType ?? 'all'}`,
`date:${req.query.date_range ?? 'all'}`,
`amount:${req.query.amount_range ?? 'all'}`,
].join(':');

type QuarantineEventRow = {
id: number;
event_id: string;
Expand Down
14 changes: 4 additions & 10 deletions backend/src/controllers/remittanceController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,13 +151,7 @@ export const getRemittances = asyncHandler(async (req: Request, res: Response) =
queryParams: params,
});

const [result, countResult] = await Promise.all([
query(queryText, params),
query(
`SELECT COUNT(*) as count FROM remittances WHERE ${whereClause.replace(` AND seq <= $${params.length - 1}`, '')}`,
params.slice(0, -2),
),
]);
const result = await query(queryText, params);

const hasNext = result.rows.length > limit;
const remittances = hasNext ? result.rows.slice(0, limit) : result.rows;
Expand Down Expand Up @@ -210,8 +204,8 @@ export const getRemittance = asyncHandler(async (req: Request, res: Response) =>

const remittance = await remittanceService.getRemittance(id);

// Verify the user owns this remittance
if (senderAddress !== senderAddress) {
// Verify the user is a party to this remittance (sender or recipient)
if (remittance.senderId !== senderAddress && remittance.recipientAddress !== senderAddress) {
throw AppError.forbidden('You do not have access to this remittance');
}

Expand Down Expand Up @@ -248,7 +242,7 @@ export const submitRemittanceTransaction = asyncHandler(async (req: Request, res
try {
const remittance = await remittanceService.getRemittance(id);

if (remittance.senderId !== remittance.senderId) {
if (remittance.senderId !== senderAddress) {
throw AppError.forbidden('You do not have access to this remittance');
}

Expand Down
Loading
Loading