Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { createMock, DeepMocked } from '@golevelup/ts-jest';
import { BadRequestException, ValidationPipe } from '@nestjs/common';
import { GUARDS_METADATA, METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
import { RequestMethod } from '@nestjs/common/enums';
import { THROTTLER_LIMIT, THROTTLER_TTL } from '@nestjs/throttler/dist/throttler.constants';
import { JwtPayload } from 'src/shared/auth/jwt-payload.interface';
import { OptionalJwtAuthGuard } from 'src/shared/auth/optional.guard';
import { RateLimitGuard } from 'src/shared/auth/rate-limit.guard';
import { UserRole } from 'src/shared/auth/user-role.enum';
import { olkyEUR } from '../__mocks__/bank.entity.mock';
import { BankController } from '../bank.controller';
import { BankService } from '../bank.service';
import { CheckReceiveIbanDto } from '../dto/receive-iban.dto';
import { ReceiveIbanStatus } from '../dto/receive-iban.enum';

// The receiveIban check runs behind an optional guard, so the controller must forward the account of a
// present JWT and undefined otherwise - that distinction is what makes the service answer LoginRequired.
describe('BankController.checkReceiveIban', () => {
let controller: BankController;
let service: DeepMocked<BankService>;

const dto: CheckReceiveIbanDto = { iban: olkyEUR.iban };

beforeEach(() => {
service = createMock<BankService>();
controller = new BankController(service);
});

it('forwards the account of the authenticated customer and wraps the status', async () => {
service.getReceiveIbanStatus.mockResolvedValue(ReceiveIbanStatus.DFX_IBAN);

const result = await controller.checkReceiveIban({ account: 42, role: UserRole.USER } as JwtPayload, dto);

expect(service.getReceiveIbanStatus).toHaveBeenCalledWith(dto.iban, 42);
expect(result).toEqual({ status: ReceiveIbanStatus.DFX_IBAN });
});

it('forwards undefined without a JWT', async () => {
service.getReceiveIbanStatus.mockResolvedValue(ReceiveIbanStatus.LOGIN_REQUIRED);

const result = await controller.checkReceiveIban(undefined, dto);

expect(service.getReceiveIbanStatus).toHaveBeenCalledWith(dto.iban, undefined);
expect(result).toEqual({ status: ReceiveIbanStatus.LOGIN_REQUIRED });
});
});

// The two tests above pass the JWT in directly, so they cannot see the decorators that decide whether a JWT
// is ever attached. Removing @UseGuards entirely leaves them green while every logged-in customer would get
// LoginRequired (req.user never set), and swapping in a hard AuthGuard() would 401 every anonymous customer.
describe('BankController.checkReceiveIban routing & security metadata', () => {
const handler = BankController.prototype.checkReceiveIban;

it('is mounted as PUT bank/receiveIban', () => {
expect(Reflect.getMetadata(PATH_METADATA, BankController)).toBe('bank');
expect(Reflect.getMetadata(PATH_METADATA, handler)).toBe('receiveIban');
expect(Reflect.getMetadata(METHOD_METADATA, handler)).toBe(RequestMethod.PUT);
});

it('guards the route with RateLimitGuard before OptionalJwtAuthGuard', () => {
const guards = Reflect.getMetadata(GUARDS_METADATA, handler) as unknown[];

// Order matters as documented on the route; the optional guard must be the auth guard, because a hard
// AuthGuard() would reject exactly the anonymous callers this endpoint exists to serve.
expect(guards).toEqual([RateLimitGuard, OptionalJwtAuthGuard]);
});

it('carries a route-level throttle, which is what gives the guard a limit at all', () => {
// RateLimitGuard resolves `routeOrClassLimit || this.options.limit`, and ThrottlerModule.forRoot() is
// registered without options - so without this decorator nothing would be throttled.
expect(Reflect.getMetadata(THROTTLER_LIMIT, handler)).toBe(60);
expect(Reflect.getMetadata(THROTTLER_TTL, handler)).toBe(60);
});
});

// CheckReceiveIbanDto deliberately carries no @Transform. The Util transform helpers call string methods on
// the raw value - sanitizeString does value.trim() behind a bare truthiness check, trimAll does
// value?.replace(...) - and @Transform runs before @IsString, so a non-string body throws a TypeError that
// the exception filter turns into a 500 on a route reachable without a login. Adding a transform here would
// look like tidying up; these cases pin the boundary behaviour rather than the absence of a decorator.
describe('CheckReceiveIbanDto validation boundary', () => {
// Same configuration as the global pipe in main.ts.
const pipe = new ValidationPipe({ whitelist: true, transformOptions: { exposeUnsetFields: false } });
const metadata = { type: 'body' as const, metatype: CheckReceiveIbanDto };

it.each([123, [], {}, null, undefined, ''])(
'rejects %p with a BadRequestException, never a TypeError',
async (iban) => {
await expect(pipe.transform({ iban }, metadata)).rejects.toBeInstanceOf(BadRequestException);
},
);

it('passes a plain string through unchanged, leaving normalization to the service', async () => {
await expect(pipe.transform({ iban: ' LI75-0881-1010-5923-K000E ' }, metadata)).resolves.toEqual({
iban: ' LI75-0881-1010-5923-K000E ',
});
});
});

// These four strings are the wire contract: the client library carries its own copy of this enum, and the
// support form derives its wording from it. Comparing enum members would let a renamed value pass here and
// break at runtime in the browser instead, so the literals are asserted.
describe('ReceiveIbanStatus wire values', () => {
it('serializes to the strings the consumers expect', () => {
expect(ReceiveIbanStatus.DFX_IBAN).toBe('DfxIban');
expect(ReceiveIbanStatus.NOT_MATCHED).toBe('NotMatched');
expect(ReceiveIbanStatus.INVALID_IBAN).toBe('InvalidIban');
expect(ReceiveIbanStatus.LOGIN_REQUIRED).toBe('LoginRequired');
});
});
239 changes: 239 additions & 0 deletions src/subdomains/supporting/bank/bank/__tests__/bank.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,12 @@ import { createDefaultUserData } from 'src/subdomains/generic/user/models/user-d
import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity';
import { UserService } from 'src/subdomains/generic/user/models/user/user.service';
import { BankAccountService } from 'src/subdomains/supporting/bank/bank-account/bank-account.service';
import { createCustomVirtualIban } from 'src/subdomains/supporting/bank/virtual-iban/__mocks__/virtual-iban.entity.mock';
import { VirtualIban, VirtualIbanStatus } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban.entity';
import { VirtualIbanRepository } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban.repository';
import { FiatPaymentMethod } from 'src/subdomains/supporting/payment/dto/payment-method.enum';
import {
createCustomBank,
createDefaultBanks,
createDefaultDisabledBanks,
yapealCHF,
Expand All @@ -26,6 +30,7 @@ import { Bank } from '../bank.entity';
import { BankRepository } from '../bank.repository';
import { BankSelectorInput, BankService } from '../bank.service';
import { IbanBankName } from '../dto/bank.dto';
import { ReceiveIbanStatus } from '../dto/receive-iban.enum';

function createBankSelectorInput(
currency = 'EUR',
Expand Down Expand Up @@ -69,6 +74,7 @@ describe('BankService', () => {
{ provide: FiatService, useValue: fiatService },
{ provide: CountryService, useValue: countryService },
{ provide: BankAccountService, useValue: bankAccountService },
{ provide: VirtualIbanRepository, useValue: createMock<VirtualIbanRepository>() },
TestUtil.provideConfig(),
],
}).compile();
Expand Down Expand Up @@ -225,6 +231,7 @@ describe('Bank (name, currency) collision tie-break', () => {
{ provide: FiatService, useValue: createMock<FiatService>() },
{ provide: CountryService, useValue: createMock<CountryService>() },
{ provide: BankAccountService, useValue: createMock<BankAccountService>() },
{ provide: VirtualIbanRepository, useValue: createMock<VirtualIbanRepository>() },
TestUtil.provideConfig(),
],
}).compile();
Expand Down Expand Up @@ -369,3 +376,235 @@ describe('Bank (name, currency) collision tie-break', () => {
expect(BankService.isBankMatching(asset, 'YAPEAL-UNBOUND-NEWER-IBAN')).toBe(false);
});
});

describe('BankService.getReceiveIbanStatus', () => {
const accountId = 42;
const otherAccountId = 43;

// A retired collective account: same IBAN a customer may have transferred to years ago, but receive=false today.
const retiredCollectiveAccount = createCustomBank({ iban: 'CH5604835012345678009', receive: false, send: false });
const personalIban = 'DE89370400440532013000';
const expiredPersonalIban = 'AT483200000012345864';
const foreignPersonalIban = 'CH4431999123000889012';

let service: BankService;
let bankRepo: BankRepository;
let virtualIbanRepo: VirtualIbanRepository;

beforeEach(async () => {
bankRepo = createMock<BankRepository>();
virtualIbanRepo = createMock<VirtualIbanRepository>();

const module: TestingModule = await Test.createTestingModule({
imports: [TestSharedModule],
providers: [
BankService,
{ provide: BankRepository, useValue: bankRepo },
{ provide: VirtualIbanRepository, useValue: virtualIbanRepo },
TestUtil.provideConfig(),
],
}).compile();

service = module.get<BankService>(BankService);
});

// The only shape getReceiveIbanStatus passes to findCachedBy; keeps the mock typed without `any`.
type AccountScopedWhere = { userData: { id: number } };

function setup(banks: Bank[], virtualIbansByAccount: Map<number, VirtualIban[]> = new Map()): void {
jest.spyOn(bankRepo, 'findCached').mockResolvedValue(banks);
jest
.spyOn(virtualIbanRepo, 'findCachedBy')
.mockImplementation(
async (_key: string | number, where: AccountScopedWhere) => virtualIbansByAccount.get(where.userData.id) ?? [],
);
}

it('reports a collective account IBAN as a DFX IBAN, without asking for personal IBANs', async () => {
// A collective account hit short-circuits for a logged-in caller too - no account-scoped lookup happens.
setup(createDefaultBanks());

await expect(service.getReceiveIbanStatus(olkyEUR.iban, accountId)).resolves.toBe(ReceiveIbanStatus.DFX_IBAN);
expect(virtualIbanRepo.findCachedBy).not.toHaveBeenCalled();
});

it('reports a collective account IBAN as a DFX IBAN without a login, before ever asking for personal IBANs', async () => {
// The bank check must run before the login check, otherwise a logged-out customer gets LoginRequired for
// an IBAN we can already confirm.
setup(createDefaultBanks());

await expect(service.getReceiveIbanStatus(olkyEUR.iban)).resolves.toBe(ReceiveIbanStatus.DFX_IBAN);
expect(virtualIbanRepo.findCachedBy).not.toHaveBeenCalled();
});

it('reports a collective account IBAN stored in paper format as a DFX IBAN', async () => {
// The stored side is normalized too, so a row that carries a grouped value still matches.
setup([createCustomBank({ iban: 'LU11 6060 0020 0000 5040' })]);

await expect(service.getReceiveIbanStatus('LU116060002000005040', accountId)).resolves.toBe(
ReceiveIbanStatus.DFX_IBAN,
);
});

it('reports a personal IBAN stored in paper format as a DFX IBAN', async () => {
// The comparison normalizes stored virtual_iban values as well, so their format need not be guaranteed.
setup(
createDefaultBanks(),
new Map([[accountId, [createCustomVirtualIban({ iban: 'de89 3704 0044 0532 0130 00' })]]]),
);

await expect(service.getReceiveIbanStatus(personalIban, accountId)).resolves.toBe(ReceiveIbanStatus.DFX_IBAN);
});

it('reports a collective account IBAN with receive=false as a DFX IBAN', async () => {
// A retired or closed account still received DFX money, and a missing transfer can predate it being stood down.
setup([retiredCollectiveAccount]);

await expect(service.getReceiveIbanStatus(retiredCollectiveAccount.iban, accountId)).resolves.toBe(
ReceiveIbanStatus.DFX_IBAN,
);
});

it('reports a personal IBAN of the requesting account as a DFX IBAN', async () => {
setup(createDefaultBanks(), new Map([[accountId, [createCustomVirtualIban({ iban: personalIban })]]]));

await expect(service.getReceiveIbanStatus(personalIban, accountId)).resolves.toBe(ReceiveIbanStatus.DFX_IBAN);
expect(virtualIbanRepo.findCachedBy).toHaveBeenCalledWith(`user-${accountId}`, { userData: { id: accountId } });
});

it.each([VirtualIbanStatus.EXPIRED, VirtualIbanStatus.DEACTIVATED, VirtualIbanStatus.RESERVED])(
'reports a personal IBAN with status %s as a DFX IBAN',
async (status) => {
// An expired personal IBAN was still a real receiving IBAN, so no lifecycle state may be filtered out.
setup(
createDefaultBanks(),
new Map([[accountId, [createCustomVirtualIban({ iban: expiredPersonalIban, active: false, status })]]]),
);

await expect(service.getReceiveIbanStatus(expiredPersonalIban, accountId)).resolves.toBe(
ReceiveIbanStatus.DFX_IBAN,
);
},
);

it('reports a formally invalid IBAN as invalid, without querying any IBAN', async () => {
setup(createDefaultBanks());

await expect(service.getReceiveIbanStatus('DE123456', accountId)).resolves.toBe(ReceiveIbanStatus.INVALID_IBAN);
expect(bankRepo.findCached).not.toHaveBeenCalled();
expect(virtualIbanRepo.findCachedBy).not.toHaveBeenCalled();
});

it('reports a correctly shaped IBAN with a wrong checksum as invalid, not as unmatched', async () => {
// A changed digit keeps the country and length intact, so only the checksum catches it. Answering
// NotMatched here would send a customer looking for a transfer that never left with a typo in the IBAN.
setup(createDefaultBanks());

await expect(service.getReceiveIbanStatus('DE89370400440532013001', accountId)).resolves.toBe(
ReceiveIbanStatus.INVALID_IBAN,
);
});

it.each([undefined, null, '', ' '])(
'reports an unusable input (%p) as invalid instead of throwing',
async (input) => {
// Defensive only: @IsString/@IsNotEmpty reject undefined, null and '' with a 400 before the service is
// reached, so of these only ' ' can actually arrive. The typeof guard in normalizeIban short-circuits
// the non-string cases, and an all-separator string normalizes to '' and is returned as null.
// The cast stays because getReceiveIbanStatus itself declares `iban: string`; it is what lets the test
// reach the guard from outside the type system, which is exactly the situation the guard exists for.
setup(createDefaultBanks());

await expect(service.getReceiveIbanStatus(input as string, accountId)).resolves.toBe(
ReceiveIbanStatus.INVALID_IBAN,
);
},
);

it('reports a valid IBAN that matched neither list as not matched when the customer is logged in', async () => {
setup(createDefaultBanks());

await expect(service.getReceiveIbanStatus(foreignPersonalIban, accountId)).resolves.toBe(
ReceiveIbanStatus.NOT_MATCHED,
);
});

it('requires a login for a valid unmatched IBAN, because personal IBANs stay unchecked without one', async () => {
setup(createDefaultBanks());

await expect(service.getReceiveIbanStatus(foreignPersonalIban)).resolves.toBe(ReceiveIbanStatus.LOGIN_REQUIRED);
expect(virtualIbanRepo.findCachedBy).not.toHaveBeenCalled();
});

it('recognizes the same IBAN written with grouping spaces and in lower case', async () => {
setup(createDefaultBanks(), new Map([[accountId, [createCustomVirtualIban({ iban: personalIban })]]]));

await expect(service.getReceiveIbanStatus('lu11 6060 0020 0000 5040', accountId)).resolves.toBe(
ReceiveIbanStatus.DFX_IBAN,
);
await expect(service.getReceiveIbanStatus('de89 3704 0044 0532 0130 00', accountId)).resolves.toBe(
ReceiveIbanStatus.DFX_IBAN,
);
});

// The invisible separators are written as escape sequences on purpose: it makes them visible in review and
// lowers the risk of an edit or a copy-paste quietly normalizing them into ordinary spaces, which would
// void exactly those cases.
it.each([
['an ASCII space', ' '],
['a hyphen', '-'],
['a dot', '.'],
['a slash', '/'],
['a non-breaking space', '\u00a0'],
['a narrow non-breaking space', '\u202f'],
['a zero-width space', '\u200b'],
['a soft hyphen', '\u00ad'],
['a tab', '\t'],
['a line break', '\n'],
])('recognizes an IBAN grouped with %s', async (_name, separator) => {
setup([frickEUR], new Map([[accountId, [createCustomVirtualIban({ iban: personalIban })]]]));

const group = (iban: string): string => (iban.match(/.{1,4}/g) ?? []).join(separator);

await expect(service.getReceiveIbanStatus(group(frickEUR.iban), accountId)).resolves.toBe(
ReceiveIbanStatus.DFX_IBAN,
);
await expect(service.getReceiveIbanStatus(group(personalIban), accountId)).resolves.toBe(
ReceiveIbanStatus.DFX_IBAN,
);
});

it('recognizes an IBAN pasted with surrounding quotes', async () => {
setup([frickEUR]);

await expect(service.getReceiveIbanStatus('"LI75 0881 1010 5923 K000E"', accountId)).resolves.toBe(
ReceiveIbanStatus.DFX_IBAN,
);
});

it('does not extract an IBAN out of surrounding ASCII words', async () => {
// Separators are stripped, an ASCII label is not: it survives normalization and makes the value invalid,
// which is what we want - a prefix is indistinguishable from extra characters that corrupt the IBAN.
// The guarantee is ASCII-only by construction: a label in a non-Latin script is stripped like a
// separator and the IBAN is accepted. Harmless, but the reason this test says "ASCII".
setup([frickEUR]);

await expect(service.getReceiveIbanStatus('IBAN: LI75 0881 1010 5923 K000E', accountId)).resolves.toBe(
ReceiveIbanStatus.INVALID_IBAN,
);
});

it('never reports a personal IBAN of another account as a DFX IBAN', async () => {
setup(
createDefaultBanks(),
new Map([
[accountId, [createCustomVirtualIban({ iban: personalIban })]],
[otherAccountId, [createCustomVirtualIban({ iban: foreignPersonalIban })]],
]),
);

await expect(service.getReceiveIbanStatus(foreignPersonalIban, accountId)).resolves.toBe(
ReceiveIbanStatus.NOT_MATCHED,
);
});
});
Loading