From fc9b16d35480d49a4867cc429096d01fb317b360 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 17:04:58 +0000 Subject: [PATCH] fix: blacklist transaction sort fields from query parameters Repeated orderBy query keys are arrays, and string checks such as includes() do not mean the same thing for arrays as they do for strings. Sort fields are now matched explicitly, and unknown values fall back to timestamp. Co-authored-by: David --- services/transactionService.ts | 125 +++++++++------------ tests/unittests/transactionService.test.ts | 68 +++++++++++ 2 files changed, 122 insertions(+), 71 deletions(-) diff --git a/services/transactionService.ts b/services/transactionService.ts index 221905d2..c503a183 100644 --- a/services/transactionService.ts +++ b/services/transactionService.ts @@ -177,6 +177,57 @@ export async function fetchTransactionsByAddressList ( }) } +type TransactionOrderBy = Prisma.TransactionOrderByWithRelationInput + +// Repeated query keys arrive as arrays. Only known fields become Prisma orderBy +// objects; anything else, including arrays, sorts by timestamp. +function transactionOrderBy (orderBy: unknown, orderDesc: boolean): TransactionOrderBy { + const direction: Prisma.SortOrder = orderDesc ? 'desc' : 'asc' + if (typeof orderBy !== 'string') { + return { timestamp: direction } + } + switch (orderBy) { + case 'id': + return { id: direction } + case 'hash': + return { hash: direction } + case 'amount': + return { amount: direction } + case 'confirmed': + return { confirmed: direction } + case 'createdAt': + return { createdAt: direction } + case 'address.address': + return { address: { address: direction } } + case 'address.networkId': + return { address: { networkId: direction } } + case 'timestamp': + default: + return { timestamp: direction } + } +} + +function paymentOrderBy (orderBy: unknown, orderDesc: boolean): TransactionOrderBy { + const direction: Prisma.SortOrder = orderDesc ? 'desc' : 'asc' + if (typeof orderBy !== 'string') { + return { timestamp: direction } + } + switch (orderBy) { + case 'id': + return { id: direction } + case 'hash': + return { hash: direction } + case 'amount': + case 'values': + return { amount: direction } + case 'networkId': + return { address: { networkId: direction } } + case 'timestamp': + default: + return { timestamp: direction } + } +} + export async function fetchTransactionsByAddressListWithPagination ( addressIdList: string[], page: number, @@ -186,29 +237,7 @@ export async function fetchTransactionsByAddressListWithPagination ( networkIdsListFilter?: number[], includeInputs = false ): Promise { - const orderDescString: Prisma.SortOrder = orderDesc ? 'desc' : 'asc' - - // Get query for orderBy that works with nested properties (e.g. `address.networkId`) - let orderByQuery - if (orderBy !== undefined && orderBy !== '') { - if (orderBy.includes('.')) { - const [relation, property] = orderBy.split('.') - orderByQuery = { - [relation]: { - [property]: orderDescString - } - } - } else { - orderByQuery = { - [orderBy]: orderDescString - } - } - } else { - // Default orderBy - orderByQuery = { - timestamp: orderDescString - } - } + const orderByQuery = transactionOrderBy(orderBy, orderDesc) // Build include conditionally - exclude inputs by default unless explicitly requested const include = includeInputs @@ -288,29 +317,7 @@ export async function * generateTransactionsWithPaybuttonsAndPricesForAddress (a } export async function fetchPaginatedAddressTransactions (addressString: string, page: number, pageSize: number, orderBy?: string, orderDesc = true): Promise { - const orderDescString: Prisma.SortOrder = orderDesc ? 'desc' : 'asc' - - // Get query for orderBy that works with nested properties (e.g. `address.networkId`) - let orderByQuery - if (orderBy !== undefined && orderBy !== '') { - if (orderBy.includes('.')) { - const [relation, property] = orderBy.split('.') - orderByQuery = { - [relation]: { - [property]: orderDescString - } - } - } else { - orderByQuery = { - [orderBy]: orderDescString - } - } - } else { - // Default orderBy - orderByQuery = { - timestamp: orderDescString - } - } + const orderByQuery = transactionOrderBy(orderBy, orderDesc) const parsedAddress = parseAddress(addressString) await addressExists(parsedAddress, true) const txs = await prisma.transaction.findMany({ @@ -1161,36 +1168,12 @@ export async function fetchAllPaymentsByUserIdWithPagination ( endDate?: string, includeInputs = false ): Promise { - const orderDescString: Prisma.SortOrder = orderDesc ? 'desc' : 'asc' - if (orderBy === 'buttonDisplayDataList') { return await getPaymentsByUserIdOrderedByButtonName( userId, page, pageSize, orderDesc, buttonIds ) } - // Get query for orderBy that works with nested properties (e.g. `address.networkId`) - let orderByQuery - if (orderBy !== undefined && orderBy !== '') { - if (orderBy === 'values') { - orderByQuery = { - amount: orderDescString - } - } else if (orderBy === 'networkId') { - orderByQuery = { - address: { - networkId: orderDescString - } - } - } else { - orderByQuery = { - [orderBy]: orderDescString - } - } - } else { - orderByQuery = { - timestamp: orderDescString - } - } + const orderByQuery = paymentOrderBy(orderBy, orderDesc) const where: Prisma.TransactionWhereInput = { address: { diff --git a/tests/unittests/transactionService.test.ts b/tests/unittests/transactionService.test.ts index e2e3f47a..7aac61a9 100644 --- a/tests/unittests/transactionService.test.ts +++ b/tests/unittests/transactionService.test.ts @@ -2,6 +2,7 @@ import prisma from 'prisma-local/clientInstance' import * as transactionService from 'services/transactionService' import { prismaMock } from 'prisma-local/mockedClient' import { mockedBCHAddress, mockedUSDPriceOnTransaction, mockedCADPriceOnTransaction, mockedTransaction, mockedUserProfile, mockedAddressIdList, mockedTransactionList } from '../mockedObjects' +import { exampleAddresses } from '../utils' import { CacheSet } from 'redis/index' import { Prisma } from '@prisma/client' import * as addressService from 'services/addressService' @@ -455,4 +456,71 @@ describe('Date and timezone filters for transactions', () => { expect(callArgs.where.timestamp).toBeUndefined() expect(callArgs.where.OR).toBeUndefined() }) + + it('maps payment sort fields and ignores values that are not on the blacklist', async () => { + prismaMock.transaction.findMany.mockResolvedValue([]) + prisma.transaction.findMany = prismaMock.transaction.findMany + + await transactionService.fetchAllPaymentsByUserIdWithPagination( + 'user-1', 0, 10, 'UTC', 'values', false + ) + expect(prismaMock.transaction.findMany.mock.calls[0][0]).toEqual(expect.objectContaining({ + orderBy: { amount: 'asc' } + })) + + prismaMock.transaction.findMany.mockClear() + await transactionService.fetchAllPaymentsByUserIdWithPagination( + 'user-1', 0, 10, 'UTC', 'networkId', true + ) + expect(prismaMock.transaction.findMany.mock.calls[0][0]).toEqual(expect.objectContaining({ + orderBy: { address: { networkId: 'desc' } } + })) + + prismaMock.transaction.findMany.mockClear() + await transactionService.fetchAllPaymentsByUserIdWithPagination( + 'user-1', 0, 10, 'UTC', '__proto__', true + ) + expect(prismaMock.transaction.findMany.mock.calls[0][0]).toEqual(expect.objectContaining({ + orderBy: { timestamp: 'desc' } + })) + + prismaMock.transaction.findMany.mockClear() + await transactionService.fetchAllPaymentsByUserIdWithPagination( + 'user-1', 0, 10, 'UTC', ['amount', 'hash'] as unknown as string, true + ) + expect(prismaMock.transaction.findMany.mock.calls[0][0]).toEqual(expect.objectContaining({ + orderBy: { timestamp: 'desc' } + })) + }) +}) + +describe('address transaction orderBy blacklist', () => { + const address = `ecash:${exampleAddresses.ecash}` + + beforeEach(() => { + prismaMock.address.findUniqueOrThrow.mockResolvedValue(mockedBCHAddress) + prisma.address.findUniqueOrThrow = prismaMock.address.findUniqueOrThrow + prismaMock.transaction.findMany.mockResolvedValue([]) + prisma.transaction.findMany = prismaMock.transaction.findMany + }) + + it('sorts by an allowed nested address field', async () => { + await transactionService.fetchPaginatedAddressTransactions(address, 0, 10, 'address.networkId', false) + expect(prismaMock.transaction.findMany.mock.calls[0][0]).toEqual(expect.objectContaining({ + orderBy: { address: { networkId: 'asc' } } + })) + }) + + it('falls back to timestamp when orderBy is an array or an unknown field', async () => { + await transactionService.fetchPaginatedAddressTransactions(address, 0, 10, ['timestamp'] as unknown as string, true) + expect(prismaMock.transaction.findMany.mock.calls[0][0]).toEqual(expect.objectContaining({ + orderBy: { timestamp: 'desc' } + })) + + prismaMock.transaction.findMany.mockClear() + await transactionService.fetchPaginatedAddressTransactions(address, 0, 10, 'constructor', true) + expect(prismaMock.transaction.findMany.mock.calls[0][0]).toEqual(expect.objectContaining({ + orderBy: { timestamp: 'desc' } + })) + }) })