From 4d697e73652c7cd22f7a03f4fd8b44620fb62e76 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:29:23 +0200 Subject: [PATCH 1/3] fix(history): require auth for transaction list and redact public single DTO List and export endpoints no longer accept wallet address as sole credential. Callers must use JWT or CoinTracking API-key headers; userAddress is an optional ownership-scoped filter. Unauthenticated single-tx lookups return a reduced DTO without bank/chargeback targets, fee breakdowns, or external ids so status links keep working without leaking private financial data. --- ...ransaction.controller.history-auth.spec.ts | 187 ++++++++++++++ .../__tests__/transaction.controller.spec.ts | 8 +- .../history/controllers/history.controller.ts | 18 +- .../controllers/transaction.controller.ts | 108 +++++++- .../core/history/dto/history-query.dto.ts | 18 +- src/subdomains/core/history/history.module.ts | 3 +- .../transaction-dto.mapper.public.spec.ts | 99 ++++++++ .../history/mappers/transaction-dto.mapper.ts | 64 +++++ .../__tests__/history-access.service.spec.ts | 239 ++++++++++++++++++ .../__tests__/history.service.auth.spec.ts | 81 ++++++ .../services/history-access.service.ts | 135 ++++++++++ .../core/history/services/history.service.ts | 71 ++++-- 12 files changed, 984 insertions(+), 47 deletions(-) create mode 100644 src/subdomains/core/history/__tests__/transaction.controller.history-auth.spec.ts create mode 100644 src/subdomains/core/history/mappers/__tests__/transaction-dto.mapper.public.spec.ts create mode 100644 src/subdomains/core/history/services/__tests__/history-access.service.spec.ts create mode 100644 src/subdomains/core/history/services/__tests__/history.service.auth.spec.ts create mode 100644 src/subdomains/core/history/services/history-access.service.ts diff --git a/src/subdomains/core/history/__tests__/transaction.controller.history-auth.spec.ts b/src/subdomains/core/history/__tests__/transaction.controller.history-auth.spec.ts new file mode 100644 index 0000000000..ad58acb220 --- /dev/null +++ b/src/subdomains/core/history/__tests__/transaction.controller.history-auth.spec.ts @@ -0,0 +1,187 @@ +import { createMock } from '@golevelup/ts-jest'; +import { UnauthorizedException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; +import { UserRole } from 'src/shared/auth/user-role.enum'; +import { FiatService } from 'src/shared/models/fiat/fiat.service'; +import { TestSharedModule } from 'src/shared/utils/test.shared.module'; +import { TestUtil } from 'src/shared/utils/test.util'; +import { BuyCryptoService } from 'src/subdomains/core/buy-crypto/process/services/buy-crypto.service'; +import { BankDataService } from 'src/subdomains/generic/user/models/bank-data/bank-data.service'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; +import { BankTxReturnService } from 'src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service'; +import { BankTxService } from 'src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service'; +import { BankService } from 'src/subdomains/supporting/bank/bank/bank.service'; +import { createCustomTransaction } from 'src/subdomains/supporting/payment/__mocks__/transaction.entity.mock'; +import { VirtualIbanService } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban.service'; +import { SwissQRService } from 'src/subdomains/supporting/payment/services/swiss-qr.service'; +import { TransactionHelper } from 'src/subdomains/supporting/payment/services/transaction-helper'; +import { TransactionRequestService } from 'src/subdomains/supporting/payment/services/transaction-request.service'; +import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; +import { + TransactionDto, + TransactionState, + TransactionType, +} from 'src/subdomains/supporting/payment/dto/transaction.dto'; +import { BuyCryptoWebhookService } from '../../buy-crypto/process/services/buy-crypto-webhook.service'; +import { BuyService } from '../../buy-crypto/routes/buy/buy.service'; +import { BuyFiatService } from '../../sell-crypto/process/services/buy-fiat.service'; +import { TransactionUtilService } from '../../transaction/transaction-util.service'; +import { TransactionController } from '../controllers/transaction.controller'; +import { ExportFormat } from '../dto/history-query.dto'; +import { HistoryAccessService } from '../services/history-access.service'; +import { ExportType, HistoryService } from '../services/history.service'; + +describe('TransactionController history auth', () => { + let controller: TransactionController; + let historyService: jest.Mocked; + let historyAccessService: jest.Mocked; + let transactionService: jest.Mocked; + let buyCryptoWebhookService: jest.Mocked; + + const jwt: JwtPayload = { role: UserRole.USER, ip: '1.1.1.1', account: 1, user: 10, address: '0xAAA' }; + const account = { id: 1 } as UserData; + + beforeEach(async () => { + historyService = createMock(); + historyAccessService = createMock(); + transactionService = createMock(); + buyCryptoWebhookService = createMock(); + + const module: TestingModule = await Test.createTestingModule({ + imports: [TestSharedModule], + providers: [ + TransactionController, + { provide: HistoryService, useValue: historyService }, + { provide: HistoryAccessService, useValue: historyAccessService }, + { provide: TransactionService, useValue: transactionService }, + { provide: BuyCryptoWebhookService, useValue: buyCryptoWebhookService }, + { provide: BuyFiatService, useValue: createMock() }, + { provide: BankDataService, useValue: createMock() }, + { provide: BankTxService, useValue: createMock() }, + { provide: FiatService, useValue: createMock() }, + { provide: BuyService, useValue: createMock() }, + { provide: BuyCryptoService, useValue: createMock() }, + { provide: TransactionUtilService, useValue: createMock() }, + { provide: UserDataService, useValue: createMock() }, + { provide: BankTxReturnService, useValue: createMock() }, + { provide: TransactionRequestService, useValue: createMock() }, + { provide: BankService, useValue: createMock() }, + { provide: TransactionHelper, useValue: createMock() }, + { provide: SwissQRService, useValue: createMock() }, + { provide: VirtualIbanService, useValue: createMock() }, + TestUtil.provideConfig(), + ], + }).compile(); + + controller = module.get(TransactionController); + }); + + describe('getTransactions', () => { + it('requires auth via HistoryAccessService and loads history for subject', async () => { + historyAccessService.resolveListSubject.mockResolvedValue(account); + historyService.getHistoryForSubject.mockResolvedValue([]); + + const res = {} as any; + await controller.getTransactions(jwt, undefined, undefined, undefined, { format: ExportFormat.JSON }, res); + + expect(historyAccessService.resolveListSubject).toHaveBeenCalledWith({ + jwt, + userAddress: undefined, + apiKey: undefined, + apiSign: undefined, + apiTimestamp: undefined, + }); + expect(historyService.getHistoryForSubject).toHaveBeenCalledWith( + account, + expect.objectContaining({ format: ExportFormat.JSON }), + ExportType.COMPACT, + ); + }); + + it('propagates UnauthorizedException when access is denied', async () => { + historyAccessService.resolveListSubject.mockRejectedValue(new UnauthorizedException('Authentication required')); + + await expect( + controller.getTransactions(undefined, undefined, undefined, undefined, {}, {} as any), + ).rejects.toBeInstanceOf(UnauthorizedException); + expect(historyService.getHistoryForSubject).not.toHaveBeenCalled(); + }); + }); + + describe('createCsv / CoinTracking / ChainReport', () => { + it('createCsv authenticates then caches file key', async () => { + historyAccessService.resolveListSubject.mockResolvedValue(account); + const stream = { getStream: () => null } as any; + historyService.getCsvHistoryForSubject.mockResolvedValue(stream); + + const key = await controller.createCsv(jwt, undefined, undefined, undefined, {}); + expect(typeof key).toBe('string'); + expect(key.length).toBeGreaterThan(0); + expect(historyService.getCsvHistoryForSubject).toHaveBeenCalled(); + }); + + it('getCsvCT uses authenticated subject', async () => { + historyAccessService.resolveListSubject.mockResolvedValue(account); + historyService.getHistoryForSubject.mockResolvedValue([]); + const res = { set: jest.fn() } as any; + + await controller.getCsvCT(jwt, 'k', 's', 't', { format: ExportFormat.JSON }, res); + expect(historyAccessService.resolveListSubject).toHaveBeenCalledWith( + expect.objectContaining({ apiKey: 'k', apiSign: 's', apiTimestamp: 't' }), + ); + }); + + it('getCsvChainReport uses authenticated subject', async () => { + historyAccessService.resolveListSubject.mockResolvedValue(account); + historyService.getHistoryForSubject.mockResolvedValue([]); + const res = { set: jest.fn() } as any; + + await controller.getCsvChainReport(jwt, undefined, undefined, undefined, { format: ExportFormat.JSON }, res); + expect(historyService.getHistoryForSubject).toHaveBeenCalledWith( + account, + expect.anything(), + ExportType.CHAIN_REPORT, + ); + }); + }); + + describe('getSingleTransaction', () => { + const fullDto = Object.assign(new TransactionDto(), { + uid: 'Tabcdefghijklmnop', + type: TransactionType.BUY, + state: TransactionState.COMPLETED, + chargebackTarget: 'CH9300762011623852957', + fees: { total: 1 }, + externalTransactionId: 'secret', + date: new Date(), + }); + + beforeEach(() => { + const tx = createCustomTransaction({ userData: account as any }); + transactionService.getTransactionByUid = jest.fn().mockResolvedValue(tx); + // force path through getTransactionByUid + jest.spyOn(controller as any, 'getTransaction').mockResolvedValue(tx); + jest.spyOn(controller as any, 'getTransactionDto').mockResolvedValue(fullDto); + }); + + it('returns full dto for owner/staff', async () => { + historyAccessService.canViewFullTransaction.mockReturnValue(true); + + const result = await controller.getSingleTransaction(jwt, 'Tabcdefghijklmnop'); + expect(result).toBe(fullDto); + expect(result.chargebackTarget).toBe('CH9300762011623852957'); + }); + + it('returns public dto without private fields for anonymous', async () => { + historyAccessService.canViewFullTransaction.mockReturnValue(false); + + const result = await controller.getSingleTransaction(undefined, 'Tabcdefghijklmnop'); + expect(result.chargebackTarget).toBeUndefined(); + expect((result as TransactionDto).fees).toBeUndefined(); + expect((result as TransactionDto).externalTransactionId).toBeUndefined(); + expect(result.uid).toBe('Tabcdefghijklmnop'); + }); + }); +}); diff --git a/src/subdomains/core/history/__tests__/transaction.controller.spec.ts b/src/subdomains/core/history/__tests__/transaction.controller.spec.ts index 3fc907eb62..eba1391d15 100644 --- a/src/subdomains/core/history/__tests__/transaction.controller.spec.ts +++ b/src/subdomains/core/history/__tests__/transaction.controller.spec.ts @@ -23,20 +23,20 @@ import { CheckStatus } from '../../aml/enums/check-status.enum'; import { createCustomBuyCrypto } from '../../buy-crypto/process/entities/__mocks__/buy-crypto.entity.mock'; import { BuyCryptoWebhookService } from '../../buy-crypto/process/services/buy-crypto-webhook.service'; import { BuyService } from '../../buy-crypto/routes/buy/buy.service'; -import { RefRewardService } from '../../referral/reward/services/ref-reward.service'; import { BuyFiatService } from '../../sell-crypto/process/services/buy-fiat.service'; import { TransactionUtilService } from '../../transaction/transaction-util.service'; import { TransactionController } from '../controllers/transaction.controller'; +import { HistoryAccessService } from '../services/history-access.service'; import { HistoryService } from '../services/history.service'; describe('TransactionController', () => { let controller: TransactionController; let historyService: HistoryService; + let historyAccessService: HistoryAccessService; let transactionService: TransactionService; let buyCryptoWebhookService: BuyCryptoWebhookService; let buyFiatService: BuyFiatService; - let refRewardService: RefRewardService; let bankDataService: BankDataService; let bankTxService: BankTxService; let fiatService: FiatService; @@ -53,10 +53,10 @@ describe('TransactionController', () => { beforeEach(async () => { historyService = createMock(); + historyAccessService = createMock(); transactionService = createMock(); buyCryptoWebhookService = createMock(); buyFiatService = createMock(); - refRewardService = createMock(); bankDataService = createMock(); bankTxService = createMock(); fiatService = createMock(); @@ -76,10 +76,10 @@ describe('TransactionController', () => { providers: [ TransactionController, { provide: HistoryService, useValue: historyService }, + { provide: HistoryAccessService, useValue: historyAccessService }, { provide: TransactionService, useValue: transactionService }, { provide: BuyCryptoWebhookService, useValue: buyCryptoWebhookService }, { provide: BuyFiatService, useValue: buyFiatService }, - { provide: RefRewardService, useValue: refRewardService }, { provide: BankDataService, useValue: bankDataService }, { provide: BankTxService, useValue: bankTxService }, { provide: FiatService, useValue: fiatService }, diff --git a/src/subdomains/core/history/controllers/history.controller.ts b/src/subdomains/core/history/controllers/history.controller.ts index d5119ea6f4..1e6611be33 100644 --- a/src/subdomains/core/history/controllers/history.controller.ts +++ b/src/subdomains/core/history/controllers/history.controller.ts @@ -35,6 +35,7 @@ import { ExportFormat, HistoryQuery, HistoryQueryExportType, HistoryQueryUser } import { TypedHistoryDto } from '../dto/history.dto'; import { ChainReportApiHistoryDto } from '../dto/output/chain-report-history.dto'; import { CoinTrackingApiHistoryDto } from '../dto/output/coin-tracking-history.dto'; +import { HistoryAccessService } from '../services/history-access.service'; import { ExportType, HistoryService } from '../services/history.service'; import { TransactionController } from './transaction.controller'; @@ -46,6 +47,7 @@ export class HistoryController { constructor( private readonly historyService: HistoryService, + private readonly historyAccessService: HistoryAccessService, private readonly transactionController: TransactionController, private readonly userService: UserService, private readonly userDataService: UserDataService, @@ -58,11 +60,16 @@ export class HistoryController { @ApiOkResponse({ type: TypedHistoryDto, isArray: true }) @ApiExcludeEndpoint() async getHistory( + @GetJwt() jwt: JwtPayload, @Query() query: HistoryQueryUser, @Response({ passthrough: true }) res, ): Promise { if (!query.format) query.format = ExportFormat.JSON; - return this.transactionController.getHistoryData(query, ExportType.COMPACT, res); + const subject = await this.historyAccessService.resolveListSubject({ + jwt, + userAddress: query.userAddress ?? jwt.address, + }); + return this.transactionController.getHistoryDataForSubject(subject, query, ExportType.COMPACT, res); } @Get(':exportType') @@ -101,8 +108,13 @@ export class HistoryController { @ApiExcludeEndpoint() @ApiCreatedResponse() async createCsv(@GetJwt() jwt: JwtPayload, @Query() query: HistoryQueryExportType): Promise { - const csvFile = await this.historyService.getCsvHistory( - { ...query, userAddress: jwt.address, format: ExportFormat.CSV }, + const subject = await this.historyAccessService.resolveListSubject({ + jwt, + userAddress: jwt.address, + }); + const csvFile = await this.historyService.getCsvHistoryForSubject( + subject, + { ...query, format: ExportFormat.CSV }, query.type, ); const fileKey = Util.randomString(16); diff --git a/src/subdomains/core/history/controllers/transaction.controller.ts b/src/subdomains/core/history/controllers/transaction.controller.ts index ca1a33f530..fef1ebb36a 100644 --- a/src/subdomains/core/history/controllers/transaction.controller.ts +++ b/src/subdomains/core/history/controllers/transaction.controller.ts @@ -5,6 +5,7 @@ import { Controller, ForbiddenException, Get, + Headers, NotFoundException, Param, ParseIntPipe, @@ -23,6 +24,7 @@ import { Config } from 'src/config/config'; import { GetJwt } from 'src/shared/auth/get-jwt.decorator'; import { IpGuard } from 'src/shared/auth/ip.guard'; import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; +import { OptionalJwtAuthGuard } from 'src/shared/auth/optional.guard'; import { RoleGuard } from 'src/shared/auth/role.guard'; import { UserActiveGuard } from 'src/shared/auth/user-active.guard'; import { UserRole } from 'src/shared/auth/user-role.enum'; @@ -74,7 +76,7 @@ import { RefReward } from '../../referral/reward/ref-reward.entity'; import { BuyFiat } from '../../sell-crypto/process/buy-fiat.entity'; import { BuyFiatService } from '../../sell-crypto/process/services/buy-fiat.service'; import { TransactionUtilService } from '../../transaction/transaction-util.service'; -import { ExportFormat, HistoryQueryUser } from '../dto/history-query.dto'; +import { ExportFormat, HistoryQuery, HistoryQueryUser } from '../dto/history-query.dto'; import { HistoryDto } from '../dto/history.dto'; import { ChainReportCsvHistoryDto } from '../dto/output/chain-report-history.dto'; import { CoinTrackingCsvHistoryDto } from '../dto/output/coin-tracking-history.dto'; @@ -83,6 +85,7 @@ import { BaseRefund } from '../dto/refund-internal.dto'; import { TransactionFilter } from '../dto/transaction-filter.dto'; import { TransactionRefundDto } from '../dto/transaction-refund.dto'; import { TransactionDtoMapper } from '../mappers/transaction-dto.mapper'; +import { HistoryAccessService, HistorySubject } from '../services/history-access.service'; import { ExportType, HistoryService } from '../services/history.service'; @ApiTags('Transaction') @@ -93,6 +96,7 @@ export class TransactionController { constructor( private readonly historyService: HistoryService, + private readonly historyAccessService: HistoryAccessService, private readonly transactionService: TransactionService, private readonly buyCryptoWebhookService: BuyCryptoWebhookService, private readonly buyFiatService: BuyFiatService, @@ -119,23 +123,43 @@ export class TransactionController { } } - // --- OPEN ENDPOINTS --- // + // --- HISTORY LIST / EXPORT (auth required: JWT or CT API key) --- // @Get() + @ApiBearerAuth() + @UseGuards(OptionalJwtAuthGuard) @ApiOkResponse({ type: TransactionDto, isArray: true }) + @ApiOperation({ + description: + 'Transaction history for the authenticated subject (Bearer JWT or DFX-ACCESS-KEY/SIGN/TIMESTAMP). ' + + 'Optional userAddress must belong to the subject.', + }) async getTransactions( + @GetJwt() jwt: JwtPayload | undefined, + @Headers('DFX-ACCESS-KEY') apiKey: string, + @Headers('DFX-ACCESS-SIGN') apiSign: string, + @Headers('DFX-ACCESS-TIMESTAMP') apiTimestamp: string, @Query() query: HistoryQueryUser, @Res({ passthrough: true }) res: Response, ): Promise { if (!query.format) query.format = ExportFormat.JSON; - return this.getHistoryData(query, ExportType.COMPACT, res); + const subject = await this.resolveListSubject(jwt, query.userAddress, apiKey, apiSign, apiTimestamp); + return this.getHistoryDataForSubject(subject, query, ExportType.COMPACT, res); } + /** + * Public status lookup by capability identifier (UID / CKO id / order UID). + * Unauthenticated callers receive a reduced public DTO (no IBAN/fees/external ids). + * Owner or staff JWT receives the full compact DTO. + */ @Get('single') + @UseGuards(OptionalJwtAuthGuard) + @ApiBearerAuth() @ApiOkResponse({ type: TransactionDto }) @ApiQuery({ name: 'uid', description: 'Transaction unique ID', required: false }) @ApiQuery({ name: 'order-uid', description: 'Order unique ID', required: false }) @ApiQuery({ name: 'cko-id', description: 'CKO ID', required: false }) async getSingleTransaction( + @GetJwt() jwt: JwtPayload | undefined, @Query('uid') uid?: string, @Query('order-uid') orderUid?: string, @Query('cko-id') ckoId?: string, @@ -145,21 +169,39 @@ export class TransactionController { const dto = await this.getTransactionDto(tx); if (!dto) throw new NotFoundException('Transaction not found'); - return dto; + if (this.historyAccessService.canViewFullTransaction(jwt, tx)) return dto; + + return TransactionDtoMapper.toPublicDto(dto); } @Put('csv') + @ApiBearerAuth() + @UseGuards(OptionalJwtAuthGuard) @ApiOkResponse() - @ApiOperation({ description: 'Initiate CSV history export' }) - async createCsv(@Query() query: HistoryQueryUser): Promise { - const csvFile = await this.historyService.getCsvHistory({ ...query, format: ExportFormat.CSV }, ExportType.COMPACT); + @ApiOperation({ description: 'Initiate CSV history export (requires JWT or CT API key)' }) + async createCsv( + @GetJwt() jwt: JwtPayload | undefined, + @Headers('DFX-ACCESS-KEY') apiKey: string, + @Headers('DFX-ACCESS-SIGN') apiSign: string, + @Headers('DFX-ACCESS-TIMESTAMP') apiTimestamp: string, + @Query() query: HistoryQueryUser, + ): Promise { + const subject = await this.resolveListSubject(jwt, query.userAddress, apiKey, apiSign, apiTimestamp); + const csvFile = await this.historyService.getCsvHistoryForSubject( + subject, + { ...query, format: ExportFormat.CSV }, + ExportType.COMPACT, + ); return this.cacheCsv(csvFile); } @Get('csv') @ApiOkResponse({ type: StreamableFile }) - @ApiOperation({ description: 'Get initiated CSV history export' }) + @ApiOperation({ + description: + 'Download a previously initiated CSV export by one-time key. Key is only issued after authenticated createCsv.', + }) async getCsv(@Query('key') key: string, @Res({ passthrough: true }) res: Response): Promise { const csvFile = this.files[key]; if (!csvFile) throw new NotFoundException('File not found'); @@ -171,25 +213,39 @@ export class TransactionController { } @Get('CoinTracking') + @ApiBearerAuth() + @UseGuards(OptionalJwtAuthGuard) @ApiOkResponse({ type: CoinTrackingCsvHistoryDto, isArray: true }) @ApiExcludeEndpoint() async getCsvCT( + @GetJwt() jwt: JwtPayload | undefined, + @Headers('DFX-ACCESS-KEY') apiKey: string, + @Headers('DFX-ACCESS-SIGN') apiSign: string, + @Headers('DFX-ACCESS-TIMESTAMP') apiTimestamp: string, @Query() query: HistoryQueryUser, @Res({ passthrough: true }) res: Response, ): Promise { if (!query.format) query.format = ExportFormat.CSV; - return this.getHistoryData(query, ExportType.COIN_TRACKING, res); + const subject = await this.resolveListSubject(jwt, query.userAddress, apiKey, apiSign, apiTimestamp); + return this.getHistoryDataForSubject(subject, query, ExportType.COIN_TRACKING, res); } @Get('ChainReport') + @ApiBearerAuth() + @UseGuards(OptionalJwtAuthGuard) @ApiOkResponse({ type: ChainReportCsvHistoryDto, isArray: true }) @ApiExcludeEndpoint() async getCsvChainReport( + @GetJwt() jwt: JwtPayload | undefined, + @Headers('DFX-ACCESS-KEY') apiKey: string, + @Headers('DFX-ACCESS-SIGN') apiSign: string, + @Headers('DFX-ACCESS-TIMESTAMP') apiTimestamp: string, @Query() query: HistoryQueryUser, @Res({ passthrough: true }) res: Response, ): Promise { if (!query.format) query.format = ExportFormat.CSV; - return this.getHistoryData(query, ExportType.CHAIN_REPORT, res); + const subject = await this.resolveListSubject(jwt, query.userAddress, apiKey, apiSign, apiTimestamp); + return this.getHistoryDataForSubject(subject, query, ExportType.CHAIN_REPORT, res); } // --- AUTHORIZED ENDPOINTS --- // @@ -650,7 +706,10 @@ export class TransactionController { return Util.secondsDiff(refundData.expiryDate) <= 0; } - public async getHistoryData( + /** + * @deprecated Prefer {@link getHistoryDataForSubject} after HistoryAccessService resolution. + */ + private async getHistoryData( query: HistoryQueryUser, exportType: T, res: any, @@ -660,6 +719,33 @@ export class TransactionController { return tx; } + public async getHistoryDataForSubject( + subject: HistorySubject, + query: HistoryQuery, + exportType: T, + res: any, + ): Promise[] | StreamableFile> { + const tx = await this.historyService.getHistoryForSubject(subject, query, exportType); + if (query.format === ExportFormat.CSV) this.setCsvResult(res, exportType); + return tx; + } + + private async resolveListSubject( + jwt: JwtPayload | undefined, + userAddress: string | undefined, + apiKey: string | undefined, + apiSign: string | undefined, + apiTimestamp: string | undefined, + ): Promise { + return this.historyAccessService.resolveListSubject({ + jwt, + userAddress, + apiKey, + apiSign, + apiTimestamp, + }); + } + private cacheCsv(csvFile: StreamableFile): string { const fileKey = Util.randomString(16); this.files[fileKey] = csvFile; diff --git a/src/subdomains/core/history/dto/history-query.dto.ts b/src/subdomains/core/history/dto/history-query.dto.ts index 03e5631607..f95a26003f 100644 --- a/src/subdomains/core/history/dto/history-query.dto.ts +++ b/src/subdomains/core/history/dto/history-query.dto.ts @@ -1,6 +1,6 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsDate, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; +import { IsDate, IsEnum, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { ExportType } from '../services/history.service'; import { HistoryFilter } from './history-filter.dto'; @@ -50,10 +50,18 @@ export class HistoryQuery extends HistoryFilter { } export class HistoryQueryUser extends HistoryQuery { - @ApiProperty() - @IsNotEmpty() + /** + * Optional wallet filter. When omitted, JWT/API-key subject scope is used + * (account-level history for account tokens, wallet for user tokens). + * When set, the address must belong to the authenticated subject. + */ + @ApiPropertyOptional({ + description: + 'Optional wallet address filter; must belong to the authenticated subject. When omitted, subject scope is used.', + }) + @IsOptional() @IsString() - userAddress: string; + userAddress?: string; } export class HistoryQueryExportType extends HistoryQuery { diff --git a/src/subdomains/core/history/history.module.ts b/src/subdomains/core/history/history.module.ts index b4524fa54b..46a40f56d3 100644 --- a/src/subdomains/core/history/history.module.ts +++ b/src/subdomains/core/history/history.module.ts @@ -13,6 +13,7 @@ import { StakingModule } from '../staking/staking.module'; import { TransactionUtilModule } from '../transaction/transaction-util.module'; import { HistoryController } from './controllers/history.controller'; import { TransactionController } from './controllers/transaction.controller'; +import { HistoryAccessService } from './services/history-access.service'; import { HistoryService } from './services/history.service'; @Module({ @@ -31,7 +32,7 @@ import { HistoryService } from './services/history.service'; BankModule, ], controllers: [HistoryController, TransactionController], - providers: [HistoryService, TransactionController], + providers: [HistoryService, HistoryAccessService, TransactionController], exports: [], }) export class HistoryModule {} diff --git a/src/subdomains/core/history/mappers/__tests__/transaction-dto.mapper.public.spec.ts b/src/subdomains/core/history/mappers/__tests__/transaction-dto.mapper.public.spec.ts new file mode 100644 index 0000000000..4586398574 --- /dev/null +++ b/src/subdomains/core/history/mappers/__tests__/transaction-dto.mapper.public.spec.ts @@ -0,0 +1,99 @@ +import { + TransactionDto, + TransactionState, + TransactionType, + UnassignedTransactionDto, +} from 'src/subdomains/supporting/payment/dto/transaction.dto'; +import { TransactionDtoMapper } from '../transaction-dto.mapper'; + +describe('TransactionDtoMapper.toPublicDto', () => { + it('strips private fields from a full TransactionDto', () => { + const full = Object.assign(new TransactionDto(), { + id: 42, + uid: 'Tabcdefghijklmnop', + orderUid: 'Qxyz', + type: TransactionType.BUY, + state: TransactionState.COMPLETED, + reason: undefined, + inputAmount: 100, + inputAsset: 'EUR', + inputAssetId: 1, + inputPaymentMethod: 'Bank', + outputAmount: 0.01, + outputAsset: 'BTC', + outputTxUrl: 'https://explorer/tx/1', + depositAddress: 'bc1qsecret', + chargebackTarget: 'CH9300762011623852957', + chargebackAmount: 50, + chargebackAsset: 'EUR', + chargebackTxId: 'remittance-secret', + chargebackTxUrl: 'https://explorer/chargeback/secret', + fees: { total: 1, dfx: 0.5, rate: 0.01 }, + feeAmount: 1, + feeAsset: 'EUR', + priceSteps: [{ source: 'a', target: 'b', price: 1 }] as any, + externalTransactionId: 'partner-secret', + networkStartTx: { txId: 'n1', txUrl: 'u', amount: 1, exchangeRate: 1, asset: 'ETH' }, + exchangeRate: 10000, + date: new Date('2024-01-01'), + } as unknown as TransactionDto); + + const pub = TransactionDtoMapper.toPublicDto(full) as TransactionDto; + + expect(pub.uid).toBe(full.uid); + expect(pub.id).toBe(42); + expect(pub.state).toBe(TransactionState.COMPLETED); + expect(pub.inputAmount).toBe(100); + expect(pub.outputAmount).toBe(0.01); + expect(pub.exchangeRate).toBe(10000); + expect(pub.outputTxUrl).toBe('https://explorer/tx/1'); + expect(pub.chargebackAmount).toBe(50); + + // stripped + expect(pub.chargebackTarget).toBeUndefined(); + expect(pub.depositAddress).toBeUndefined(); + expect(pub.chargebackTxId).toBeUndefined(); + expect(pub.chargebackTxUrl).toBeUndefined(); + expect(pub.fees).toBeUndefined(); + expect(pub.feeAmount).toBeUndefined(); + expect(pub.priceSteps).toBeUndefined(); + expect(pub.externalTransactionId).toBeUndefined(); + expect(pub.networkStartTx).toBeUndefined(); + }); + + it('strips IBAN from UnassignedTransactionDto chargebackTarget', () => { + const unassigned = Object.assign(new UnassignedTransactionDto(), { + uid: 'Tzzzzzzzzzzzzzzzz', + type: TransactionType.BUY, + state: TransactionState.UNASSIGNED, + inputAmount: 10, + inputAsset: 'CHF', + chargebackTarget: 'DE89370400440532013000', + chargebackAmount: 10, + date: new Date(), + } as UnassignedTransactionDto); + + const pub = TransactionDtoMapper.toPublicDto(unassigned); + + expect(pub.uid).toBe(unassigned.uid); + expect(pub.chargebackTarget).toBeUndefined(); + expect(pub.chargebackAmount).toBe(10); + expect(pub).toBeInstanceOf(UnassignedTransactionDto); + }); + + it('does not mutate the original dto', () => { + const full = Object.assign(new TransactionDto(), { + uid: 'Tabcdefghijklmnop', + type: TransactionType.SELL, + state: TransactionState.PROCESSING, + chargebackTarget: 'CH9300762011623852957', + fees: { total: 2 }, + date: new Date(), + } as TransactionDto); + + TransactionDtoMapper.toPublicDto(full); + + expect(full.chargebackTarget).toBe('CH9300762011623852957'); + expect(full.fees).toEqual({ total: 2 }); + }); +}); diff --git a/src/subdomains/core/history/mappers/transaction-dto.mapper.ts b/src/subdomains/core/history/mappers/transaction-dto.mapper.ts index 80400f1206..63b4ac47b5 100644 --- a/src/subdomains/core/history/mappers/transaction-dto.mapper.ts +++ b/src/subdomains/core/history/mappers/transaction-dto.mapper.ts @@ -320,6 +320,70 @@ export class TransactionDtoMapper { return refRewards.map(TransactionDtoMapper.mapReferralReward); } + /** + * Public status payload for unauthenticated `GET /transaction/single` (capability UID/CKO link). + * Keeps fields needed for the status UI (state, amounts, explorer links) and strips + * private banking/compliance/fee-detail data that is not on-chain public information. + */ + static toPublicDto(dto: TransactionDto | UnassignedTransactionDto): TransactionDto | UnassignedTransactionDto { + const base: UnassignedTransactionDto = { + id: dto.id, + uid: dto.uid, + orderUid: dto.orderUid, + type: dto.type, + state: dto.state, + inputAmount: dto.inputAmount, + inputAsset: dto.inputAsset, + inputAssetId: dto.inputAssetId, + inputChainId: dto.inputChainId, + inputBlockchain: dto.inputBlockchain, + inputEvmChainId: dto.inputEvmChainId, + inputPaymentMethod: dto.inputPaymentMethod, + inputTxId: dto.inputTxId, + inputTxUrl: dto.inputTxUrl, + // depositAddress, chargebackTarget/IBAN, chargeback remittance — not public + depositAddress: undefined, + chargebackTarget: undefined, + chargebackAmount: dto.chargebackAmount != null ? dto.chargebackAmount : undefined, + chargebackAsset: dto.chargebackAsset, + chargebackAssetId: dto.chargebackAssetId, + chargebackTxId: undefined, + chargebackTxUrl: undefined, + chargebackDate: dto.chargebackDate, + date: dto.date, + }; + + if (!('reason' in dto) && !('outputAmount' in dto) && !('fees' in dto)) { + return Object.assign(new UnassignedTransactionDto(), base); + } + + const full = dto as TransactionDto; + const publicFull: TransactionDto = { + ...base, + reason: full.reason, + exchangeRate: full.exchangeRate, + rate: full.rate, + outputAmount: full.outputAmount, + outputAsset: full.outputAsset, + outputAssetId: full.outputAssetId, + outputChainId: full.outputChainId, + outputBlockchain: full.outputBlockchain, + outputEvmChainId: full.outputEvmChainId, + outputPaymentMethod: full.outputPaymentMethod, + outputTxId: full.outputTxId, + outputTxUrl: full.outputTxUrl, + outputDate: full.outputDate, + priceSteps: undefined, + feeAmount: undefined, + feeAsset: undefined, + fees: undefined, + externalTransactionId: undefined, + networkStartTx: undefined, + }; + + return Object.assign(new TransactionDto(), publicFull); + } + // UnassignedTx static mapUnassignedTransaction(tx: BankTx, currency: Fiat, bankTxReturn?: BankTxReturn): UnassignedTransactionDto { return { diff --git a/src/subdomains/core/history/services/__tests__/history-access.service.spec.ts b/src/subdomains/core/history/services/__tests__/history-access.service.spec.ts new file mode 100644 index 0000000000..03e35cc249 --- /dev/null +++ b/src/subdomains/core/history/services/__tests__/history-access.service.spec.ts @@ -0,0 +1,239 @@ +import { ForbiddenException, UnauthorizedException } from '@nestjs/common'; +import { createMock } from '@golevelup/ts-jest'; +import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; +import { UserRole } from 'src/shared/auth/user-role.enum'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; +import { User } from 'src/subdomains/generic/user/models/user/user.entity'; +import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; +import { TransactionRequest } from 'src/subdomains/supporting/payment/entities/transaction-request.entity'; +import { Transaction } from 'src/subdomains/supporting/payment/entities/transaction.entity'; +import { HistoryAccessService } from '../history-access.service'; + +describe('HistoryAccessService', () => { + let service: HistoryAccessService; + let userService: jest.Mocked; + let userDataService: jest.Mocked; + + const accountUsers = [{ id: 10, address: '0xAAA' } as User, { id: 11, address: '0xBBB' } as User]; + const account = { id: 1, users: accountUsers } as UserData; + + beforeEach(() => { + userService = createMock(); + userDataService = createMock(); + service = new HistoryAccessService(userService, userDataService); + }); + + describe('resolveListSubject', () => { + it('rejects when neither JWT nor API key is provided', async () => { + await expect(service.resolveListSubject({})).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('rejects JWT without account', async () => { + await expect( + service.resolveListSubject({ jwt: { role: UserRole.USER, ip: '1.1.1.1' } as JwtPayload }), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('returns account history for account JWT without address filter', async () => { + userDataService.getUserData.mockResolvedValue(account); + + const subject = await service.resolveListSubject({ + jwt: { role: UserRole.ACCOUNT, ip: '1.1.1.1', account: 1 }, + }); + + expect(subject).toBe(account); + expect(userDataService.getUserData).toHaveBeenCalledWith(1, { users: true }); + }); + + it('scopes JWT to jwt.address when no userAddress query', async () => { + userDataService.getUserData.mockResolvedValue(account); + + const subject = await service.resolveListSubject({ + jwt: { role: UserRole.USER, ip: '1.1.1.1', account: 1, address: '0xaaa', user: 10 }, + }); + + expect(subject).toMatchObject({ id: 10, address: '0xAAA' }); + expect((subject as User).userData).toBe(account); + }); + + it('allows userAddress that belongs to the account (case-insensitive)', async () => { + userDataService.getUserData.mockResolvedValue(account); + + const subject = await service.resolveListSubject({ + jwt: { role: UserRole.USER, ip: '1.1.1.1', account: 1, address: '0xAAA', user: 10 }, + userAddress: '0xbbb', + }); + + expect(subject).toMatchObject({ id: 11, address: '0xBBB' }); + }); + + it('forbids userAddress that does not belong to the account', async () => { + userDataService.getUserData.mockResolvedValue(account); + + await expect( + service.resolveListSubject({ + jwt: { role: UserRole.USER, ip: '1.1.1.1', account: 1, address: '0xAAA', user: 10 }, + userAddress: '0xEVIL', + }), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('rejects when JWT account cannot be loaded', async () => { + userDataService.getUserData.mockResolvedValue(null); + + await expect( + service.resolveListSubject({ + jwt: { role: UserRole.ACCOUNT, ip: '1.1.1.1', account: 99 }, + }), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('resolves user API key (suffix 0) without address filter', async () => { + const user = { id: 10, address: '0xAAA', apiKeyCT: 'KEY0' } as User; + userService.checkApiKey.mockResolvedValue(user); + + const subject = await service.resolveListSubject({ + apiKey: 'KEY0', + apiSign: 'sig', + apiTimestamp: new Date().toISOString(), + }); + + expect(subject).toBe(user); + expect(userService.checkApiKey).toHaveBeenCalled(); + }); + + it('forbids address mismatch on user API key', async () => { + const user = { id: 10, address: '0xAAA', apiKeyCT: 'KEY0' } as User; + userService.checkApiKey.mockResolvedValue(user); + + await expect( + service.resolveListSubject({ + apiKey: 'KEY0', + apiSign: 'sig', + apiTimestamp: 't', + userAddress: '0xBBB', + }), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('resolves account API key and optional address filter', async () => { + const ud = { id: 1, users: accountUsers, apiKeyCT: 'ACCT1' } as UserData; + userDataService.checkApiKey.mockResolvedValue(ud); + + const subject = await service.resolveListSubject({ + apiKey: 'ACCT1', + apiSign: 'sig', + apiTimestamp: 't', + userAddress: '0xBBB', + }); + + expect(subject).toMatchObject({ id: 11, address: '0xBBB' }); + }); + + it('forbids address not on account API key', async () => { + const ud = { id: 1, users: accountUsers, apiKeyCT: 'ACCT1' } as UserData; + userDataService.checkApiKey.mockResolvedValue(ud); + + await expect( + service.resolveListSubject({ + apiKey: 'ACCT1', + apiSign: 'sig', + apiTimestamp: 't', + userAddress: '0xZZZ', + }), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('prefers JWT over API key when both present', async () => { + userDataService.getUserData.mockResolvedValue(account); + + await service.resolveListSubject({ + jwt: { role: UserRole.ACCOUNT, ip: '1.1.1.1', account: 1 }, + apiKey: 'KEY0', + apiSign: 's', + apiTimestamp: 't', + }); + + expect(userService.checkApiKey).not.toHaveBeenCalled(); + expect(userDataService.getUserData).toHaveBeenCalled(); + }); + }); + + describe('canViewFullTransaction / isOwner', () => { + const ownerJwt: JwtPayload = { role: UserRole.USER, ip: '1.1.1.1', account: 1, user: 10, address: '0xAAA' }; + const otherJwt: JwtPayload = { role: UserRole.USER, ip: '1.1.1.1', account: 2, user: 20, address: '0xCCC' }; + + it('denies full view without JWT', () => { + const tx = { userData: { id: 1 } } as Transaction; + expect(service.canViewFullTransaction(undefined, tx)).toBe(false); + expect(service.isOwner(undefined, tx)).toBe(false); + }); + + it('allows owner of Transaction via userData.id', () => { + const tx = { userData: { id: 1 } } as Transaction; + expect(service.isOwner(ownerJwt, tx)).toBe(true); + expect(service.canViewFullTransaction(ownerJwt, tx)).toBe(true); + }); + + it('allows owner of TransactionRequest via user.userData.id', () => { + const tx = { user: { userData: { id: 1 } } } as TransactionRequest; + expect(service.isOwner(ownerJwt, tx)).toBe(true); + }); + + it('denies non-owner', () => { + const tx = { userData: { id: 1 } } as Transaction; + expect(service.isOwner(otherJwt, tx)).toBe(false); + expect(service.canViewFullTransaction(otherJwt, tx)).toBe(false); + }); + + it('allows SUPPORT staff full access to any tx', () => { + const staff: JwtPayload = { role: UserRole.SUPPORT, ip: '1.1.1.1', account: 99 }; + const tx = { userData: { id: 1 } } as Transaction; + expect(service.canViewFullTransaction(staff, tx)).toBe(true); + expect(service.isOwner(staff, tx)).toBe(false); + }); + + it('allows COMPLIANCE via SUPPORT hierarchy', () => { + const staff: JwtPayload = { role: UserRole.COMPLIANCE, ip: '1.1.1.1', account: 99 }; + const tx = { userData: { id: 1 } } as Transaction; + expect(service.canViewFullTransaction(staff, tx)).toBe(true); + }); + + it('allows ADMIN via hierarchy', () => { + const staff: JwtPayload = { role: UserRole.ADMIN, ip: '1.1.1.1', account: 99 }; + const tx = { userData: { id: 1 } } as Transaction; + expect(service.canViewFullTransaction(staff, tx)).toBe(true); + }); + + it('allows REALUNIT role full access', () => { + const staff: JwtPayload = { role: UserRole.REALUNIT, ip: '1.1.1.1', account: 99 }; + const tx = { userData: { id: 1 } } as Transaction; + expect(service.canViewFullTransaction(staff, tx)).toBe(true); + }); + + it('returns false for missing tx', () => { + expect(service.canViewFullTransaction(ownerJwt, undefined)).toBe(false); + }); + + it('extracts account from Transaction.userData.id via isOwner', () => { + const tx = { userData: { id: 5 } } as Transaction; + const jwt: JwtPayload = { role: UserRole.USER, ip: '1.1.1.1', account: 5 }; + expect(service.isOwner(jwt, tx)).toBe(true); + expect(service.canViewFullTransaction(jwt, tx)).toBe(true); + }); + + it('extracts account from TransactionRequest.user.userData.id via isOwner', () => { + const tx = { user: { userData: { id: 7 } } } as TransactionRequest; + const jwt: JwtPayload = { role: UserRole.USER, ip: '1.1.1.1', account: 7 }; + expect(service.isOwner(jwt, tx)).toBe(true); + expect(service.canViewFullTransaction(jwt, tx)).toBe(true); + }); + + it('denies ownership when tx has no account linkage', () => { + const jwt: JwtPayload = { role: UserRole.USER, ip: '1.1.1.1', account: 1 }; + expect(service.isOwner(jwt, {} as Transaction)).toBe(false); + expect(service.canViewFullTransaction(jwt, {} as Transaction)).toBe(false); + }); + }); +}); diff --git a/src/subdomains/core/history/services/__tests__/history.service.auth.spec.ts b/src/subdomains/core/history/services/__tests__/history.service.auth.spec.ts new file mode 100644 index 0000000000..6dcb324410 --- /dev/null +++ b/src/subdomains/core/history/services/__tests__/history.service.auth.spec.ts @@ -0,0 +1,81 @@ +import { NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { createMock } from '@golevelup/ts-jest'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { User } from 'src/subdomains/generic/user/models/user/user.entity'; +import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; +import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; +import { BuyCryptoWebhookService } from '../../../buy-crypto/process/services/buy-crypto-webhook.service'; +import { BuyFiatService } from '../../../sell-crypto/process/services/buy-fiat.service'; +import { StakingService } from '../../../staking/services/staking.service'; +import { ExportFormat } from '../../dto/history-query.dto'; +import { ExportType, HistoryService } from '../history.service'; + +describe('HistoryService auth-facing methods', () => { + let service: HistoryService; + let userService: jest.Mocked; + let transactionService: jest.Mocked; + + beforeEach(() => { + userService = createMock(); + transactionService = createMock(); + service = new HistoryService( + userService, + createMock(), + createMock(), + createMock(), + transactionService, + ); + }); + + it('getHistoryForSubject rejects missing subject', async () => { + await expect( + service.getHistoryForSubject(undefined as unknown as User, { format: ExportFormat.JSON }, ExportType.COMPACT), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('getHistoryForSubject uses account transactions for UserData', async () => { + const account = Object.assign(new UserData(), { id: 5, users: [] }); + transactionService.getTransactionsForAccount.mockResolvedValue([]); + + const result = await service.getHistoryForSubject(account, { format: ExportFormat.JSON }, ExportType.COMPACT); + + expect(transactionService.getTransactionsForAccount).toHaveBeenCalledWith( + 5, + undefined, + undefined, + undefined, + undefined, + ); + expect(result).toEqual([]); + }); + + it('getJsonHistory uses user transactions for User', async () => { + const user = Object.assign(new User(), { id: 9, address: '0x1' }); + transactionService.getTransactionsForUsers.mockResolvedValue([]); + + const result = await service.getJsonHistory(user, { format: ExportFormat.JSON }, ExportType.COMPACT); + + expect(transactionService.getTransactionsForUsers).toHaveBeenCalledWith( + [9], + undefined, + undefined, + undefined, + undefined, + ); + expect(result).toEqual([]); + }); + + it('getHistory always throws UnauthorizedException', async () => { + await expect(service.getHistory({}, ExportType.COMPACT)).rejects.toBeInstanceOf(UnauthorizedException); + await expect(service.getHistory({ userAddress: '0x1' }, ExportType.COMPACT)).rejects.toBeInstanceOf( + UnauthorizedException, + ); + expect(userService.getUserByAddress).not.toHaveBeenCalled(); + }); + + it('getCsvHistory always throws UnauthorizedException', async () => { + await expect(service.getCsvHistory({ userAddress: '0x1' }, ExportType.COMPACT)).rejects.toBeInstanceOf( + UnauthorizedException, + ); + }); +}); diff --git a/src/subdomains/core/history/services/history-access.service.ts b/src/subdomains/core/history/services/history-access.service.ts new file mode 100644 index 0000000000..c657c98344 --- /dev/null +++ b/src/subdomains/core/history/services/history-access.service.ts @@ -0,0 +1,135 @@ +import { ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common'; +import { hasRoleAccess } from 'src/shared/auth/role.guard'; +import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; +import { UserRole } from 'src/shared/auth/user-role.enum'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; +import { User } from 'src/subdomains/generic/user/models/user/user.entity'; +import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; +import { TransactionRequest } from 'src/subdomains/supporting/payment/entities/transaction-request.entity'; +import { Transaction } from 'src/subdomains/supporting/payment/entities/transaction.entity'; + +export type HistorySubject = User | UserData; + +export interface HistoryListCredentials { + jwt?: JwtPayload; + userAddress?: string; + apiKey?: string; + apiSign?: string; + apiTimestamp?: string; +} + +/** + * Resolves who may load transaction history and whether a single-tx response may include + * private fields. List/export endpoints require JWT or CT API-key auth; address filters are + * ownership-checked against the authenticated subject (fail-closed). + */ +@Injectable() +export class HistoryAccessService { + constructor( + private readonly userService: UserService, + private readonly userDataService: UserDataService, + ) {} + + /** + * Authenticate a history list/export call. + * - Bearer JWT (account required) preferred + * - else DFX-ACCESS-KEY + SIGN + TIMESTAMP (CoinTracking-style) + * - `userAddress` is only a scope filter and must belong to the subject + */ + async resolveListSubject(creds: HistoryListCredentials): Promise { + if (creds.jwt?.account != null) { + return this.resolveFromJwt(creds.jwt, creds.userAddress); + } + + if (creds.apiKey && creds.apiSign && creds.apiTimestamp) { + return this.resolveFromApiKey(creds.apiKey, creds.apiSign, creds.apiTimestamp, creds.userAddress); + } + + throw new UnauthorizedException('Authentication required'); + } + + /** + * Full (non-public) single-tx payload is allowed for the owner account or staff that + * already has SUPPORT-or-higher access via the role hierarchy. + */ + canViewFullTransaction(jwt: JwtPayload | undefined, tx: Transaction | TransactionRequest | undefined): boolean { + if (!jwt?.role) return false; + if (this.isStaffFullAccess(jwt.role)) return true; + return this.isOwner(jwt, tx); + } + + isOwner(jwt: JwtPayload | undefined, tx: Transaction | TransactionRequest | undefined): boolean { + if (!jwt?.account || !tx) return false; + const accountId = this.accountIdOf(tx); + return accountId != null && accountId === jwt.account; + } + + private isStaffFullAccess(role: UserRole): boolean { + // SUPPORT hierarchy includes COMPLIANCE / ADMIN / SUPER_ADMIN via hasRoleAccess. + return hasRoleAccess(UserRole.SUPPORT, role) || hasRoleAccess(UserRole.REALUNIT, role); + } + + private accountIdOf(tx: Transaction | TransactionRequest): number | undefined { + // Duck-typed: Transaction has userData; TransactionRequest has user.userData (and sometimes both). + const withUserData = tx as { userData?: { id?: number }; user?: { userData?: { id?: number } } }; + return withUserData.userData?.id ?? withUserData.user?.userData?.id; + } + + private async resolveFromJwt(jwt: JwtPayload, userAddress?: string): Promise { + const account = await this.userDataService.getUserData(jwt.account, { users: true }); + if (!account) throw new UnauthorizedException(); + + const addressFilter = userAddress?.trim() || jwt.address?.trim(); + if (!addressFilter) { + // Account token without address → full account history + return account; + } + + const owned = this.findOwnedUser(account, addressFilter); + if (!owned) throw new ForbiddenException('Address does not belong to this account'); + + owned.userData = account; + return owned; + } + + private async resolveFromApiKey( + apiKey: string, + apiSign: string, + apiTimestamp: string, + userAddress?: string, + ): Promise { + // User keys end with version digit `0` (see ApiKeyService / history.controller); account keys differ. + // Avoid `instanceof` so plain test doubles and TypeORM proxies both work. + if (apiKey.endsWith('0')) { + const user = await this.userService.checkApiKey(apiKey, apiSign, apiTimestamp); + if (userAddress?.trim() && !this.addressesEqual(user.address, userAddress.trim())) { + throw new ForbiddenException('Address does not belong to this API key'); + } + return user; + } + + const userData = await this.userDataService.checkApiKey(apiKey, apiSign, apiTimestamp); + if (!userAddress?.trim()) return userData; + + const filter = userAddress.trim(); + const users = userData.users?.length + ? userData.users + : (await this.userDataService.getUserData(userData.id, { users: true }))?.users; + + const owned = users?.find((u) => this.addressesEqual(u.address, filter)); + if (!owned) throw new ForbiddenException('Address does not belong to this API key'); + + owned.userData = userData; + return owned; + } + + private findOwnedUser(account: UserData, address: string): User | undefined { + return account.users?.find((u) => this.addressesEqual(u.address, address)); + } + + private addressesEqual(a?: string, b?: string): boolean { + if (!a || !b) return false; + return a.toLowerCase() === b.toLowerCase(); + } +} diff --git a/src/subdomains/core/history/services/history.service.ts b/src/subdomains/core/history/services/history.service.ts index 0640107633..b69699f9d1 100644 --- a/src/subdomains/core/history/services/history.service.ts +++ b/src/subdomains/core/history/services/history.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, StreamableFile } from '@nestjs/common'; +import { Injectable, NotFoundException, StreamableFile, UnauthorizedException } from '@nestjs/common'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { Util } from 'src/shared/utils/util'; import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; @@ -89,18 +89,39 @@ export class HistoryService { return (await this.getCompleteHistoryDto(user, query, exportType)) as HistoryDto[]; } + async getCsvHistoryForSubject( + subject: User | UserData, + query: HistoryQuery, + exportFormat: T, + ): Promise { + return (await this.getHistoryForSubject(subject, query, exportFormat)) as StreamableFile; + } + + /** + * @deprecated Prefer {@link getCsvHistoryForSubject} after authenticating via HistoryAccessService. + * Always rejects — unauthenticated address-based history is not allowed. + */ async getCsvHistory(query: HistoryQueryUser, exportFormat: T): Promise { return (await this.getHistory(query, exportFormat)) as StreamableFile; } - async getHistory( - query: HistoryQueryUser, + async getHistoryForSubject( + subject: User | UserData, + query: HistoryQuery, exportType: T, ): Promise[] | StreamableFile> { - const user = await this.userService.getUserByAddress(query.userAddress); - if (!user) throw new NotFoundException('User not found'); + if (!subject) throw new NotFoundException('User not found'); + return this.getCompleteHistoryDto(subject, query, exportType); + } - return this.getCompleteHistoryDto(user, query, exportType); + /** + * @deprecated Prefer {@link getHistoryForSubject}. Never resolves by address unauthenticated. + */ + async getHistory( + _query: HistoryQueryUser, + _exportType: T, + ): Promise[] | StreamableFile> { + throw new UnauthorizedException('Authentication required'); } private async getCompleteHistoryDto( @@ -116,26 +137,30 @@ export class HistoryService { return query.format === ExportFormat.CSV ? this.getCsv(txArray, exportType) : txArray; } + private isAccountSubject(subject: User | UserData): subject is UserData { + // Wallet User.address is a string; UserData.address is a postal-address object getter. + return typeof (subject as User).address !== 'string'; + } + private async getHistoryTransactions( user: User | UserData, query: HistoryQuery, ): Promise<{ buyCryptos: BuyCrypto[]; buyFiats: BuyFiat[]; refRewards: RefReward[] }> { - const transactions = - user instanceof UserData - ? await this.transactionService.getTransactionsForAccount( - user.id, - query.from, - query.to, - query.limit, - query.offset, - ) - : await this.transactionService.getTransactionsForUsers( - [user.id], - query.from, - query.to, - query.limit, - query.offset, - ); + const transactions = this.isAccountSubject(user) + ? await this.transactionService.getTransactionsForAccount( + user.id, + query.from, + query.to, + query.limit, + query.offset, + ) + : await this.transactionService.getTransactionsForUsers( + [user.id], + query.from, + query.to, + query.limit, + query.offset, + ); const all = query.buy == null && query.sell == null && query.staking == null && query.ref == null && query.lm == null; @@ -251,7 +276,7 @@ export class HistoryService { query: HistoryQuery, exportType: T, ): Promise[]> { - const userIds = user instanceof UserData ? user.users.map((u) => u.id) : [user.id]; + const userIds = this.isAccountSubject(user) ? (user.users?.map((u) => u.id) ?? []) : [user.id]; const stakingInvests = await this.stakingService.getUserInvests(userIds, query.from, query.to); const stakingRewards = await this.stakingService.getUserStakingRewards(userIds, query.from, query.to); From 9c5af9ba62d7e59905f20a40545b1f7b39f15e28 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:54:28 +0200 Subject: [PATCH 2/3] fix(history): drop unused UserService from HistoryService --- .../history/controllers/transaction.controller.ts | 13 ------------- .../services/__tests__/history.service.auth.spec.ts | 5 ----- .../core/history/services/history.service.ts | 2 -- 3 files changed, 20 deletions(-) diff --git a/src/subdomains/core/history/controllers/transaction.controller.ts b/src/subdomains/core/history/controllers/transaction.controller.ts index fef1ebb36a..de05be1fef 100644 --- a/src/subdomains/core/history/controllers/transaction.controller.ts +++ b/src/subdomains/core/history/controllers/transaction.controller.ts @@ -706,19 +706,6 @@ export class TransactionController { return Util.secondsDiff(refundData.expiryDate) <= 0; } - /** - * @deprecated Prefer {@link getHistoryDataForSubject} after HistoryAccessService resolution. - */ - private async getHistoryData( - query: HistoryQueryUser, - exportType: T, - res: any, - ): Promise[] | StreamableFile> { - const tx = await this.historyService.getHistory(query, exportType); - if (query.format === ExportFormat.CSV) this.setCsvResult(res, exportType); - return tx; - } - public async getHistoryDataForSubject( subject: HistorySubject, query: HistoryQuery, diff --git a/src/subdomains/core/history/services/__tests__/history.service.auth.spec.ts b/src/subdomains/core/history/services/__tests__/history.service.auth.spec.ts index 6dcb324410..e9e0dbbdd1 100644 --- a/src/subdomains/core/history/services/__tests__/history.service.auth.spec.ts +++ b/src/subdomains/core/history/services/__tests__/history.service.auth.spec.ts @@ -2,7 +2,6 @@ import { NotFoundException, UnauthorizedException } from '@nestjs/common'; import { createMock } from '@golevelup/ts-jest'; import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; import { User } from 'src/subdomains/generic/user/models/user/user.entity'; -import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; import { BuyCryptoWebhookService } from '../../../buy-crypto/process/services/buy-crypto-webhook.service'; import { BuyFiatService } from '../../../sell-crypto/process/services/buy-fiat.service'; @@ -12,14 +11,11 @@ import { ExportType, HistoryService } from '../history.service'; describe('HistoryService auth-facing methods', () => { let service: HistoryService; - let userService: jest.Mocked; let transactionService: jest.Mocked; beforeEach(() => { - userService = createMock(); transactionService = createMock(); service = new HistoryService( - userService, createMock(), createMock(), createMock(), @@ -70,7 +66,6 @@ describe('HistoryService auth-facing methods', () => { await expect(service.getHistory({ userAddress: '0x1' }, ExportType.COMPACT)).rejects.toBeInstanceOf( UnauthorizedException, ); - expect(userService.getUserByAddress).not.toHaveBeenCalled(); }); it('getCsvHistory always throws UnauthorizedException', async () => { diff --git a/src/subdomains/core/history/services/history.service.ts b/src/subdomains/core/history/services/history.service.ts index b69699f9d1..e078585648 100644 --- a/src/subdomains/core/history/services/history.service.ts +++ b/src/subdomains/core/history/services/history.service.ts @@ -3,7 +3,6 @@ import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.e import { Util } from 'src/shared/utils/util'; import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; import { User } from 'src/subdomains/generic/user/models/user/user.entity'; -import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; import { Readable } from 'stream'; import { TransactionDto } from '../../../supporting/payment/dto/transaction.dto'; @@ -38,7 +37,6 @@ export enum ExportType { @Injectable() export class HistoryService { constructor( - private readonly userService: UserService, private readonly buyCryptoWebhookService: BuyCryptoWebhookService, private readonly buyFiatService: BuyFiatService, private readonly stakingService: StakingService, From ed1e3c5990541a21d9fb7a60a45c99261d303f23 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:29:04 +0200 Subject: [PATCH 3/3] fix(history): close residual leaks in the public transaction DTO Gate the input and output tx identifiers on their payment method: a fiat leg reuses the same DTO fields for a private bank reference (outputTxId carries bankTx.remittanceInfo on a sell), so an unauthenticated status link handed out the remittance reference of the payout. Strip the chargeback identifiers unconditionally. The hash resolves to the chargeback target on a block explorer, and a chargeback may go to an address other than the input sender, so it can disclose an address no other public field does. Add PublicTransactionReasonMap, an exhaustively typed fail-closed allow list, and only disclose an AML reason it classifies as operational. Hiding the reason alone is not enough: KYC_REQUIRED is produced by nothing but private reasons, so the state discloses the reason on its own - mask it to the neutral sibling the same AML branch returns for any other pending check. Restore instanceof UserData in isAccountSubject. Reading the address getter dereferences the organization relation and throws for organization accounts that do not have it loaded. Drop the silent `?? []` fallback so a missing users relation fails loud instead of quietly dropping the staking history from an export. --- ...ransaction.controller.history-auth.spec.ts | 10 +- .../history/controllers/history.controller.ts | 6 +- .../controllers/transaction.controller.ts | 2 +- .../transaction-dto.mapper.public.spec.ts | 215 +++++++++++++++++- .../history/mappers/transaction-dto.mapper.ts | 43 +++- .../__tests__/history-access.service.spec.ts | 2 +- .../__tests__/history.service.auth.spec.ts | 48 +++- .../services/history-access.service.ts | 3 +- .../core/history/services/history.service.ts | 7 +- .../supporting/payment/dto/transaction.dto.ts | 46 ++++ 10 files changed, 355 insertions(+), 27 deletions(-) diff --git a/src/subdomains/core/history/__tests__/transaction.controller.history-auth.spec.ts b/src/subdomains/core/history/__tests__/transaction.controller.history-auth.spec.ts index ad58acb220..fd1381475c 100644 --- a/src/subdomains/core/history/__tests__/transaction.controller.history-auth.spec.ts +++ b/src/subdomains/core/history/__tests__/transaction.controller.history-auth.spec.ts @@ -13,17 +13,17 @@ import { UserDataService } from 'src/subdomains/generic/user/models/user-data/us import { BankTxReturnService } from 'src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service'; import { BankTxService } from 'src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service'; import { BankService } from 'src/subdomains/supporting/bank/bank/bank.service'; -import { createCustomTransaction } from 'src/subdomains/supporting/payment/__mocks__/transaction.entity.mock'; import { VirtualIbanService } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban.service'; -import { SwissQRService } from 'src/subdomains/supporting/payment/services/swiss-qr.service'; -import { TransactionHelper } from 'src/subdomains/supporting/payment/services/transaction-helper'; -import { TransactionRequestService } from 'src/subdomains/supporting/payment/services/transaction-request.service'; -import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; +import { createCustomTransaction } from 'src/subdomains/supporting/payment/__mocks__/transaction.entity.mock'; import { TransactionDto, TransactionState, TransactionType, } from 'src/subdomains/supporting/payment/dto/transaction.dto'; +import { SwissQRService } from 'src/subdomains/supporting/payment/services/swiss-qr.service'; +import { TransactionHelper } from 'src/subdomains/supporting/payment/services/transaction-helper'; +import { TransactionRequestService } from 'src/subdomains/supporting/payment/services/transaction-request.service'; +import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; import { BuyCryptoWebhookService } from '../../buy-crypto/process/services/buy-crypto-webhook.service'; import { BuyService } from '../../buy-crypto/routes/buy/buy.service'; import { BuyFiatService } from '../../sell-crypto/process/services/buy-fiat.service'; diff --git a/src/subdomains/core/history/controllers/history.controller.ts b/src/subdomains/core/history/controllers/history.controller.ts index 1e6611be33..df891b8cc6 100644 --- a/src/subdomains/core/history/controllers/history.controller.ts +++ b/src/subdomains/core/history/controllers/history.controller.ts @@ -8,7 +8,6 @@ import { Post, Query, Res, - Response, StreamableFile, UseGuards, } from '@nestjs/common'; @@ -21,6 +20,7 @@ import { ApiOkResponse, ApiTags, } from '@nestjs/swagger'; +import { Response } from 'express'; import { GetJwt } from 'src/shared/auth/get-jwt.decorator'; import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; import { RoleGuard } from 'src/shared/auth/role.guard'; @@ -62,7 +62,7 @@ export class HistoryController { async getHistory( @GetJwt() jwt: JwtPayload, @Query() query: HistoryQueryUser, - @Response({ passthrough: true }) res, + @Res({ passthrough: true }) res: Response, ): Promise { if (!query.format) query.format = ExportFormat.JSON; const subject = await this.historyAccessService.resolveListSubject({ @@ -127,7 +127,7 @@ export class HistoryController { @ApiBearerAuth() @ApiOkResponse({ type: StreamableFile }) @ApiExcludeEndpoint() - async getCsv(@Query('key') key: string, @Res({ passthrough: true }) res): Promise { + async getCsv(@Query('key') key: string, @Res({ passthrough: true }) res: Response): Promise { const csvFile = this.files[key]; if (!csvFile) throw new NotFoundException('File not found'); delete this.files[key]; diff --git a/src/subdomains/core/history/controllers/transaction.controller.ts b/src/subdomains/core/history/controllers/transaction.controller.ts index de05be1fef..315541cbcd 100644 --- a/src/subdomains/core/history/controllers/transaction.controller.ts +++ b/src/subdomains/core/history/controllers/transaction.controller.ts @@ -710,7 +710,7 @@ export class TransactionController { subject: HistorySubject, query: HistoryQuery, exportType: T, - res: any, + res: Response, ): Promise[] | StreamableFile> { const tx = await this.historyService.getHistoryForSubject(subject, query, exportType); if (query.format === ExportFormat.CSV) this.setCsvResult(res, exportType); diff --git a/src/subdomains/core/history/mappers/__tests__/transaction-dto.mapper.public.spec.ts b/src/subdomains/core/history/mappers/__tests__/transaction-dto.mapper.public.spec.ts index 4586398574..e2faee7d53 100644 --- a/src/subdomains/core/history/mappers/__tests__/transaction-dto.mapper.public.spec.ts +++ b/src/subdomains/core/history/mappers/__tests__/transaction-dto.mapper.public.spec.ts @@ -1,9 +1,12 @@ +import { CryptoPaymentMethod, FiatPaymentMethod } from 'src/subdomains/supporting/payment/dto/payment-method.enum'; import { TransactionDto, + TransactionReason, TransactionState, TransactionType, UnassignedTransactionDto, } from 'src/subdomains/supporting/payment/dto/transaction.dto'; +import { PriceStep } from 'src/subdomains/supporting/pricing/domain/entities/price'; import { TransactionDtoMapper } from '../transaction-dto.mapper'; describe('TransactionDtoMapper.toPublicDto', () => { @@ -18,9 +21,10 @@ describe('TransactionDtoMapper.toPublicDto', () => { inputAmount: 100, inputAsset: 'EUR', inputAssetId: 1, - inputPaymentMethod: 'Bank', + inputPaymentMethod: FiatPaymentMethod.BANK, outputAmount: 0.01, outputAsset: 'BTC', + outputPaymentMethod: CryptoPaymentMethod.CRYPTO, outputTxUrl: 'https://explorer/tx/1', depositAddress: 'bc1qsecret', chargebackTarget: 'CH9300762011623852957', @@ -31,7 +35,7 @@ describe('TransactionDtoMapper.toPublicDto', () => { fees: { total: 1, dfx: 0.5, rate: 0.01 }, feeAmount: 1, feeAsset: 'EUR', - priceSteps: [{ source: 'a', target: 'b', price: 1 }] as any, + priceSteps: [PriceStep.create('Kraken', 'EUR', 'BTC', 0.00001)], externalTransactionId: 'partner-secret', networkStartTx: { txId: 'n1', txUrl: 'u', amount: 1, exchangeRate: 1, asset: 'ETH' }, exchangeRate: 10000, @@ -96,4 +100,211 @@ describe('TransactionDtoMapper.toPublicDto', () => { expect(full.chargebackTarget).toBe('CH9300762011623852957'); expect(full.fees).toEqual({ total: 2 }); }); + + it('strips a fiat output transaction identifier', () => { + const full = Object.assign(new TransactionDto(), { + uid: 'Tselloutputref', + type: TransactionType.SELL, + state: TransactionState.COMPLETED, + reason: undefined, + outputPaymentMethod: FiatPaymentMethod.BANK, + outputTxId: 'DFX Payout 4711', + outputTxUrl: 'https://bank/ref-4711', + date: new Date('2024-01-01'), + } as TransactionDto); + + const pub = TransactionDtoMapper.toPublicDto(full) as TransactionDto; + + expect(pub.outputTxId).toBeUndefined(); + expect(pub.outputTxUrl).toBeUndefined(); + }); + + it('preserves a crypto output transaction identifier and URL', () => { + const full = Object.assign(new TransactionDto(), { + uid: 'Tbuyoutputtxid', + type: TransactionType.BUY, + state: TransactionState.COMPLETED, + reason: undefined, + outputPaymentMethod: CryptoPaymentMethod.CRYPTO, + outputTxId: '0xabc', + outputTxUrl: 'https://explorer/tx/0xabc', + date: new Date('2024-01-01'), + } as TransactionDto); + + const pub = TransactionDtoMapper.toPublicDto(full) as TransactionDto; + + expect(pub.outputTxId).toBe('0xabc'); + expect(pub.outputTxUrl).toBe('https://explorer/tx/0xabc'); + }); + + it('preserves only crypto input transaction identifiers', () => { + const cryptoInput = Object.assign(new TransactionDto(), { + uid: 'Tcryptoinputid', + type: TransactionType.SELL, + state: TransactionState.PROCESSING, + reason: undefined, + inputPaymentMethod: CryptoPaymentMethod.CRYPTO, + inputTxId: '0xdef', + inputTxUrl: 'https://explorer/tx/0xdef', + date: new Date('2024-01-01'), + } as TransactionDto); + const fiatInput = Object.assign(new TransactionDto(), { + uid: 'Tfiatinputref', + type: TransactionType.BUY, + state: TransactionState.PROCESSING, + reason: undefined, + inputPaymentMethod: FiatPaymentMethod.BANK, + inputTxId: 'some-bank-ref', + inputTxUrl: 'https://bank/in-ref', + date: new Date('2024-01-01'), + } as TransactionDto); + + const publicCryptoInput = TransactionDtoMapper.toPublicDto(cryptoInput) as TransactionDto; + const publicFiatInput = TransactionDtoMapper.toPublicDto(fiatInput) as TransactionDto; + + expect(publicCryptoInput.inputTxId).toBe('0xdef'); + expect(publicCryptoInput.inputTxUrl).toBe('https://explorer/tx/0xdef'); + expect(publicFiatInput.inputTxId).toBeUndefined(); + expect(publicFiatInput.inputTxUrl).toBeUndefined(); + }); + + it('strips the chargeback identifiers of a crypto input, which would resolve to the chargeback target', () => { + const full = Object.assign(new TransactionDto(), { + uid: 'Tchargebackcrypto', + type: TransactionType.SELL, + state: TransactionState.FAILED, + reason: null, + inputPaymentMethod: CryptoPaymentMethod.CRYPTO, + chargebackTarget: 'bc1qsecretchargebacktarget', + chargebackTxId: '0xchargeback', + chargebackTxUrl: 'https://explorer/tx/0xchargeback', + chargebackAmount: 25, + date: new Date('2024-01-01'), + } as TransactionDto); + + const pub = TransactionDtoMapper.toPublicDto(full) as TransactionDto; + + expect(pub.chargebackTxId).toBeUndefined(); + expect(pub.chargebackTxUrl).toBeUndefined(); + expect(pub.chargebackTarget).toBeUndefined(); + expect(pub.chargebackAmount).toBe(25); + }); + + it('strips the chargeback identifiers of a fiat input', () => { + const full = Object.assign(new TransactionDto(), { + uid: 'Tchargebackfiat', + type: TransactionType.BUY, + state: TransactionState.FAILED, + reason: null, + inputPaymentMethod: FiatPaymentMethod.BANK, + chargebackTarget: 'CH9300762011623852957', + chargebackTxId: 'DFX Chargeback 99', + chargebackTxUrl: 'https://bank/chargeback-99', + date: new Date('2024-01-01'), + } as TransactionDto); + + const pub = TransactionDtoMapper.toPublicDto(full) as TransactionDto; + + expect(pub.chargebackTxId).toBeUndefined(); + expect(pub.chargebackTxUrl).toBeUndefined(); + expect(pub.chargebackTarget).toBeUndefined(); + }); + + it.each([ + TransactionReason.SANCTION_SUSPICION, + TransactionReason.FRAUD_SUSPICION, + TransactionReason.KYC_REJECTED, + TransactionReason.USER_DELETED, + // belongs to KycRequiredReason — the whole set stays private so that a visible reason can never + // re-identify the CheckPending state that toPublicDto masks KycRequired to + TransactionReason.INSTANT_PAYMENT, + ])('strips private transaction reason %s', (reason: TransactionReason) => { + const full = Object.assign(new TransactionDto(), { + uid: 'Tprivatereason', + type: TransactionType.BUY, + state: TransactionState.FAILED, + reason, + date: new Date('2024-01-01'), + } as TransactionDto); + + const pub = TransactionDtoMapper.toPublicDto(full) as TransactionDto; + + expect(pub.reason).toBeUndefined(); + }); + + it.each([TransactionReason.MONTHLY_LIMIT_EXCEEDED, TransactionReason.KYC_DATA_NEEDED])( + 'preserves public transaction reason %s', + (reason: TransactionReason) => { + const full = Object.assign(new TransactionDto(), { + uid: 'Tpublicreason', + type: TransactionType.BUY, + state: TransactionState.FAILED, + reason, + date: new Date('2024-01-01'), + } as TransactionDto); + + const pub = TransactionDtoMapper.toPublicDto(full) as TransactionDto; + + expect(pub.reason).toBe(reason); + }, + ); + + it.each([TransactionReason.SANCTION_SUSPICION, TransactionReason.FRAUD_SUSPICION, TransactionReason.INSTANT_PAYMENT])( + 'masks the KycRequired state, which only the private reason %s can produce', + (reason: TransactionReason) => { + const suspicious = Object.assign(new TransactionDto(), { + uid: 'Tkycrequired', + type: TransactionType.BUY, + state: TransactionState.KYC_REQUIRED, + reason, + date: new Date('2024-01-01'), + } as TransactionDto); + const manualCheck = Object.assign(new TransactionDto(), { + uid: 'Tcheckpending', + type: TransactionType.BUY, + state: TransactionState.CHECK_PENDING, + reason: null, + date: new Date('2024-01-01'), + } as TransactionDto); + + const pubSuspicious = TransactionDtoMapper.toPublicDto(suspicious) as TransactionDto; + const pubManualCheck = TransactionDtoMapper.toPublicDto(manualCheck) as TransactionDto; + + // a suspicion must be indistinguishable from an ordinary pending manual check + expect(pubSuspicious.state).toBe(TransactionState.CHECK_PENDING); + expect(pubSuspicious.reason).toBeUndefined(); + expect(pubSuspicious.state).toBe(pubManualCheck.state); + expect(pubSuspicious.reason).toBe(pubManualCheck.reason); + }, + ); + + it('leaves any other state untouched', () => { + const full = Object.assign(new TransactionDto(), { + uid: 'Tlimitexceeded', + type: TransactionType.BUY, + state: TransactionState.LIMIT_EXCEEDED, + reason: TransactionReason.MONTHLY_LIMIT_EXCEEDED, + date: new Date('2024-01-01'), + } as TransactionDto); + + const pub = TransactionDtoMapper.toPublicDto(full) as TransactionDto; + + expect(pub.state).toBe(TransactionState.LIMIT_EXCEEDED); + expect(pub.reason).toBe(TransactionReason.MONTHLY_LIMIT_EXCEEDED); + }); + + it('normalises a null transaction reason to undefined', () => { + // the production mappers set `reason: null` (not undefined) whenever there is no failure reason + const full = Object.assign(new TransactionDto(), { + uid: 'Tnullreason', + type: TransactionType.BUY, + state: TransactionState.PROCESSING, + reason: null, + date: new Date('2024-01-01'), + } as TransactionDto); + + const pub = TransactionDtoMapper.toPublicDto(full) as TransactionDto; + + expect(pub.reason).toBeUndefined(); + }); }); diff --git a/src/subdomains/core/history/mappers/transaction-dto.mapper.ts b/src/subdomains/core/history/mappers/transaction-dto.mapper.ts index 63b4ac47b5..0e952c7b10 100644 --- a/src/subdomains/core/history/mappers/transaction-dto.mapper.ts +++ b/src/subdomains/core/history/mappers/transaction-dto.mapper.ts @@ -10,6 +10,7 @@ import { TransactionRequest } from 'src/subdomains/supporting/payment/entities/t import { KycRequiredReason, LimitExceededReason, + PublicTransactionReasonMap, TransactionDetailDto, TransactionDto, TransactionReason, @@ -326,12 +327,25 @@ export class TransactionDtoMapper { * private banking/compliance/fee-detail data that is not on-chain public information. */ static toPublicDto(dto: TransactionDto | UnassignedTransactionDto): TransactionDto | UnassignedTransactionDto { + // A crypto tx hash is public info (anyone can look it up on an explorer), but the fiat leg reuses + // the very same DTO fields for a private bank reference — outputTxId carries + // bankTx.remittanceInfo, see mapBuyFiatTransaction. So gate both legs on their payment method. + // The output gate is the one that actually strips a remittance reference today; the input gate is + // defense in depth, as inputTxId is only ever filled from a cryptoInput. + const isCryptoInput = dto.inputPaymentMethod === CryptoPaymentMethod.CRYPTO; + const base: UnassignedTransactionDto = { id: dto.id, uid: dto.uid, orderUid: dto.orderUid, type: dto.type, - state: dto.state, + // KYC_REQUIRED is produced only by KycRequiredReason, and every one of those reasons is + // private (see PublicTransactionReasonMap) — so the state alone would disclose the reason we + // just hid. Report the neutral sibling the same AML branch returns for a pending check with + // neither a limit nor a KYC reason: the AML check really is pending, and the case becomes + // indistinguishable from the common manual-check one. Owners and staff still get the true + // state on the full DTO. + state: dto.state === TransactionState.KYC_REQUIRED ? TransactionState.CHECK_PENDING : dto.state, inputAmount: dto.inputAmount, inputAsset: dto.inputAsset, inputAssetId: dto.inputAssetId, @@ -339,12 +353,16 @@ export class TransactionDtoMapper { inputBlockchain: dto.inputBlockchain, inputEvmChainId: dto.inputEvmChainId, inputPaymentMethod: dto.inputPaymentMethod, - inputTxId: dto.inputTxId, - inputTxUrl: dto.inputTxUrl, - // depositAddress, chargebackTarget/IBAN, chargeback remittance — not public + inputTxId: isCryptoInput ? dto.inputTxId : undefined, + inputTxUrl: isCryptoInput ? dto.inputTxUrl : undefined, + // Never hand out an account or wallet identifier directly. For a crypto input the deposit + // address stays derivable from the retained input tx — accepted, that tx is on-chain public + // anyway. The chargeback target is not: a chargeback may go to an address other than the input + // sender, and its tx hash would resolve to exactly that address on a block explorer, so the + // chargeback identifiers are stripped as well. depositAddress: undefined, chargebackTarget: undefined, - chargebackAmount: dto.chargebackAmount != null ? dto.chargebackAmount : undefined, + chargebackAmount: dto.chargebackAmount ?? undefined, chargebackAsset: dto.chargebackAsset, chargebackAssetId: dto.chargebackAssetId, chargebackTxId: undefined, @@ -358,9 +376,18 @@ export class TransactionDtoMapper { } const full = dto as TransactionDto; + + const isCryptoOutput = full.outputPaymentMethod === CryptoPaymentMethod.CRYPTO; + + // Fail-closed: only disclose the AML/compliance reason when PublicTransactionReasonMap classifies + // it as operational/actionable. A suspicion, a rejection decision, or account-state information + // about the account holder stays private. Guard against a nullish reason so we never index the + // map with null/undefined. + const isPublicReason = full.reason != null && PublicTransactionReasonMap[full.reason]; + const publicFull: TransactionDto = { ...base, - reason: full.reason, + reason: isPublicReason ? full.reason : undefined, exchangeRate: full.exchangeRate, rate: full.rate, outputAmount: full.outputAmount, @@ -370,8 +397,8 @@ export class TransactionDtoMapper { outputBlockchain: full.outputBlockchain, outputEvmChainId: full.outputEvmChainId, outputPaymentMethod: full.outputPaymentMethod, - outputTxId: full.outputTxId, - outputTxUrl: full.outputTxUrl, + outputTxId: isCryptoOutput ? full.outputTxId : undefined, + outputTxUrl: isCryptoOutput ? full.outputTxUrl : undefined, outputDate: full.outputDate, priceSteps: undefined, feeAmount: undefined, diff --git a/src/subdomains/core/history/services/__tests__/history-access.service.spec.ts b/src/subdomains/core/history/services/__tests__/history-access.service.spec.ts index 03e35cc249..d51a320c9f 100644 --- a/src/subdomains/core/history/services/__tests__/history-access.service.spec.ts +++ b/src/subdomains/core/history/services/__tests__/history-access.service.spec.ts @@ -1,5 +1,5 @@ -import { ForbiddenException, UnauthorizedException } from '@nestjs/common'; import { createMock } from '@golevelup/ts-jest'; +import { ForbiddenException, UnauthorizedException } from '@nestjs/common'; import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; import { UserRole } from 'src/shared/auth/user-role.enum'; import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; diff --git a/src/subdomains/core/history/services/__tests__/history.service.auth.spec.ts b/src/subdomains/core/history/services/__tests__/history.service.auth.spec.ts index e9e0dbbdd1..40b817f38f 100644 --- a/src/subdomains/core/history/services/__tests__/history.service.auth.spec.ts +++ b/src/subdomains/core/history/services/__tests__/history.service.auth.spec.ts @@ -1,5 +1,6 @@ -import { NotFoundException, UnauthorizedException } from '@nestjs/common'; import { createMock } from '@golevelup/ts-jest'; +import { NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { AccountType } from 'src/subdomains/generic/user/models/user-data/account-type.enum'; import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; import { User } from 'src/subdomains/generic/user/models/user/user.entity'; import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; @@ -12,13 +13,15 @@ import { ExportType, HistoryService } from '../history.service'; describe('HistoryService auth-facing methods', () => { let service: HistoryService; let transactionService: jest.Mocked; + let stakingService: jest.Mocked; beforeEach(() => { transactionService = createMock(); + stakingService = createMock(); service = new HistoryService( createMock(), createMock(), - createMock(), + stakingService, transactionService, ); }); @@ -45,6 +48,47 @@ describe('HistoryService auth-facing methods', () => { expect(result).toEqual([]); }); + it('routes an ORGANIZATION account to account-scoped history without the organization relation loaded', async () => { + const account = Object.assign(new UserData(), { id: 5, accountType: AccountType.ORGANIZATION, users: [] }); + transactionService.getTransactionsForAccount.mockResolvedValue([]); + + const result = await service.getHistoryForSubject(account, { format: ExportFormat.JSON }, ExportType.COMPACT); + + expect(transactionService.getTransactionsForAccount).toHaveBeenCalledWith( + 5, + undefined, + undefined, + undefined, + undefined, + ); + expect(result).toEqual([]); + }); + + it('getStakingTransactions maps the account users to userIds for the staking lookup', async () => { + const account = Object.assign(new UserData(), { id: 5, users: [{ id: 9 }, { id: 11 }] }); + transactionService.getTransactionsForAccount.mockResolvedValue([]); + stakingService.getUserInvests.mockResolvedValue({ deposits: [], withdrawals: [] }); + stakingService.getUserStakingRewards.mockResolvedValue([]); + stakingService.getUserStakingRefRewards.mockResolvedValue([]); + + await service.getHistoryForSubject(account, { format: ExportFormat.JSON, staking: true }, ExportType.COMPACT); + + expect(stakingService.getUserInvests).toHaveBeenCalledWith([9, 11], undefined, undefined); + expect(stakingService.getUserStakingRewards).toHaveBeenCalledWith([9, 11], undefined, undefined); + expect(stakingService.getUserStakingRefRewards).toHaveBeenCalledWith([9, 11], undefined, undefined); + }); + + it('fails loud when the users relation of an account subject is not loaded', async () => { + // every path reaching this code loads `users`; if one ever stops doing so, the staking history + // must not be silently dropped from the export + const account = Object.assign(new UserData(), { id: 5 }); + transactionService.getTransactionsForAccount.mockResolvedValue([]); + + await expect( + service.getHistoryForSubject(account, { format: ExportFormat.JSON, staking: true }, ExportType.COMPACT), + ).rejects.toThrow(TypeError); + }); + it('getJsonHistory uses user transactions for User', async () => { const user = Object.assign(new User(), { id: 9, address: '0x1' }); transactionService.getTransactionsForUsers.mockResolvedValue([]); diff --git a/src/subdomains/core/history/services/history-access.service.ts b/src/subdomains/core/history/services/history-access.service.ts index c657c98344..63b343f9a3 100644 --- a/src/subdomains/core/history/services/history-access.service.ts +++ b/src/subdomains/core/history/services/history-access.service.ts @@ -1,6 +1,6 @@ import { ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common'; -import { hasRoleAccess } from 'src/shared/auth/role.guard'; import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; +import { hasRoleAccess } from 'src/shared/auth/role.guard'; import { UserRole } from 'src/shared/auth/user-role.enum'; import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; @@ -100,7 +100,6 @@ export class HistoryAccessService { userAddress?: string, ): Promise { // User keys end with version digit `0` (see ApiKeyService / history.controller); account keys differ. - // Avoid `instanceof` so plain test doubles and TypeORM proxies both work. if (apiKey.endsWith('0')) { const user = await this.userService.checkApiKey(apiKey, apiSign, apiTimestamp); if (userAddress?.trim() && !this.addressesEqual(user.address, userAddress.trim())) { diff --git a/src/subdomains/core/history/services/history.service.ts b/src/subdomains/core/history/services/history.service.ts index e078585648..7724f3f4e7 100644 --- a/src/subdomains/core/history/services/history.service.ts +++ b/src/subdomains/core/history/services/history.service.ts @@ -136,8 +136,9 @@ export class HistoryService { } private isAccountSubject(subject: User | UserData): subject is UserData { - // Wallet User.address is a string; UserData.address is a postal-address object getter. - return typeof (subject as User).address !== 'string'; + // instanceof, not the `address` getter — UserData.address dereferences the organization + // relation for ORGANIZATION / SOLE_PROPRIETORSHIP accounts and throws when it isn't loaded. + return subject instanceof UserData; } private async getHistoryTransactions( @@ -274,7 +275,7 @@ export class HistoryService { query: HistoryQuery, exportType: T, ): Promise[]> { - const userIds = this.isAccountSubject(user) ? (user.users?.map((u) => u.id) ?? []) : [user.id]; + const userIds = this.isAccountSubject(user) ? user.users.map((u) => u.id) : [user.id]; const stakingInvests = await this.stakingService.getUserInvests(userIds, query.from, query.to); const stakingRewards = await this.stakingService.getUserStakingRewards(userIds, query.from, query.to); diff --git a/src/subdomains/supporting/payment/dto/transaction.dto.ts b/src/subdomains/supporting/payment/dto/transaction.dto.ts index e93d3270cc..93e72c8639 100644 --- a/src/subdomains/supporting/payment/dto/transaction.dto.ts +++ b/src/subdomains/supporting/payment/dto/transaction.dto.ts @@ -72,6 +72,52 @@ export const KycRequiredReason = [ export const LimitExceededReason = [TransactionReason.MONTHLY_LIMIT_EXCEEDED, TransactionReason.ANNUAL_LIMIT_EXCEEDED]; +// Fail-closed: this map is typed over every key of TransactionReason, so adding a new reason +// without classifying it here is a compile error. +// `false` = must never reach an unauthenticated caller on the public single-transaction lookup, +// either because the reason itself discloses an AML suspicion, a KYC rejection or an account +// deletion (KYC_REJECTED, FRAUD_SUSPICION, SANCTION_SUSPICION, USER_DELETED), or because +// it belongs to the KycRequiredReason set (INSTANT_PAYMENT). Hiding a reason is only half the job: +// the KYC_REQUIRED state is produced by nothing but that set, so the state alone would give the +// suspicions away — TransactionDtoMapper.toPublicDto therefore masks it to CHECK_PENDING. Keeping +// the whole set private means a visible reason can never re-identify the masked state, and the +// reason gate still holds should the state masking ever be weakened. Keep the two in sync. +// `true` = operational info the user can act on (limits, KYC/verification steps, asset/bank/country +// availability, fees, a mismatching account holder), a neutral processing state, or the +// deliberately opaque UNKNOWN bucket (AmlReason USER_BLOCKED / USER_DATA_SUSPICIOUS and friends map +// there, see TransactionReasonMapper) — safe to disclose on a status link. +export const PublicTransactionReasonMap: { [reason in TransactionReason]: boolean } = { + [TransactionReason.UNKNOWN]: true, + [TransactionReason.MONTHLY_LIMIT_EXCEEDED]: true, + [TransactionReason.ANNUAL_LIMIT_EXCEEDED]: true, + [TransactionReason.ACCOUNT_HOLDER_MISMATCH]: true, + [TransactionReason.KYC_REJECTED]: false, + [TransactionReason.FRAUD_SUSPICION]: false, + [TransactionReason.SANCTION_SUSPICION]: false, + [TransactionReason.MIN_DEPOSIT_NOT_REACHED]: true, + [TransactionReason.ASSET_NOT_AVAILABLE]: true, + [TransactionReason.ASSET_NOT_AVAILABLE_WITH_CHOSEN_BANK]: true, + [TransactionReason.STAKING_DISCONTINUED]: true, + [TransactionReason.BANK_NOT_ALLOWED]: true, + [TransactionReason.PAYMENT_ACCOUNT_NOT_ALLOWED]: true, + [TransactionReason.COUNTRY_NOT_ALLOWED]: true, + [TransactionReason.INSTANT_PAYMENT]: false, + [TransactionReason.FEE_TOO_HIGH]: true, + [TransactionReason.RECEIVER_REJECTED]: true, + [TransactionReason.CHF_ABROAD_NOT_ALLOWED]: true, + [TransactionReason.ASSET_KYC_NEEDED]: true, + [TransactionReason.CARD_NAME_MISMATCH]: true, + [TransactionReason.USER_DELETED]: false, + [TransactionReason.VIDEO_IDENT_NEEDED]: true, + [TransactionReason.MISSING_LIQUIDITY]: true, + [TransactionReason.KYC_DATA_NEEDED]: true, + [TransactionReason.BANK_TX_NEEDED]: true, + [TransactionReason.MERGE_INCOMPLETE]: true, + [TransactionReason.PHONE_VERIFICATION_NEEDED]: true, + [TransactionReason.BANK_RELEASE_PENDING]: true, + [TransactionReason.INPUT_NOT_CONFIRMED]: true, +}; + export const TransactionReasonMapper: { [key in AmlReason]: TransactionReason; } = {