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
9 changes: 9 additions & 0 deletions docs/bank-frick-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,15 @@ All values remain blank in `.env.example`. Deployment must provide:
EUR personal IBANs; opt-in — when unset, the Frick virtual-IBAN provider is unavailable and there
is no behaviour change

**vIBAN transport contract (bodyless GET Content-Type):** Bodyless vIBAN GET calls (list and detail)
must **omit** the `Content-Type` request header entirely. Production evidence: Bank Frick's vIBAN
gateway returns a signed HTTP 200 when no `Content-Type` is sent, but the production Azure
Application Gateway in front of it returns an **unsigned HTTP 403** when `Content-Type: */*` is
present. Mutating vIBAN requests (create POST, activation-approval PUT) remain signed
`Content-Type: application/json`. Request signing (`Signature` / `algorithm`) and fail-closed
response signature verification are unchanged for all vIBAN methods. The standard WebAPI path is
deliberately different and still sends `Content-Type: */*` on bodyless GETs.

`BankFrickService.isAvailable()` requires the base URL, API key, customer identifier, private signing
key and server verification key. Every request signs the exact serialized body. Every response
remains raw text until its detached `Signature` and `algorithm` headers have been verified
Expand Down
14 changes: 7 additions & 7 deletions docs/coverage-gate.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ the other.
| Gate | Config | Scope | Question it answers |
| ---------------- | ------------------------------ | ------------------------------------------ | -------------------------------------------------------- |
| Frick gate | `jest.frick.config.js` | 10 Frick files, run by 10 Frick specs only | Do _these specs alone_ fully cover _these files_? |
| Coverage ratchet | `jest.coverage-gate.config.js` | 422 files, whole suite | Has coverage regressed anywhere it was already complete? |
| Coverage ratchet | `jest.coverage-gate.config.js` | 439 files, whole suite | Has coverage regressed anywhere it was already complete? |

## What the ratchet is, and what it is not

Expand All @@ -25,8 +25,8 @@ It is a **regression gate**, not a statement about test quality:
ratchet only protects files already on the list, and that list grows by hand (see "How the list
grows"). That is the price of the threshold approach.

Of the 422 pinned files, **233 carry real logic** (they have functions and/or branches) and
**189 are purely declarative today** (NestJS modules, constant files with neither). The two groups
Of the 439 pinned files, **246 carry real logic** (they have functions and/or branches) and
**193 are purely declarative today** (NestJS modules, constant files with neither). The two groups
are kept visibly separate in the config so the count is not mistaken for test depth.

Pinning the declarative ones is deliberate and not vacuous. Istanbul reports a metric with a total
Expand Down Expand Up @@ -161,7 +161,7 @@ deleting them would be a separate cleanup.

| Class | Files | Meaning |
| -------- | ----- | ----------------------------------------------- |
| Complete | 422 | Pinned by the ratchet |
| Complete | 422 | Pinned by the ratchet at that commit |
| Partial | 1,057 | Some coverage, below 100 on at least one metric |
| None | 127 | No coverage at all |

Expand All @@ -175,7 +175,7 @@ six under `subdomains/generic/admin` have no coverage at all.
## How the list grows

Any PR may add files to `coverageThreshold` once they reach 100%.
`jest.coverage-gate.config.js` holds the 422 paths in two arrays, `PINNED_LOGIC` (logic-carrying
`jest.coverage-gate.config.js` holds the 439 paths in two arrays, `PINNED_LOGIC` (logic-carrying
files) and `PINNED_DECLARATIVE` (purely declarative files), from which `coverageThreshold` is
generated. Adding a file means appending its path to the matching array, not writing out a
`coverageThreshold` object entry by hand.
Expand Down Expand Up @@ -211,8 +211,8 @@ To regenerate the full picture, run the gate and read `coverage-gate/coverage-su
below 100, the expected response is to extend the tests. Unpinning is an explicit decision that
belongs in the PR description, not a silent edit.

That rule stays hard for the 233 logic-carrying files. A foreseeable friction case is different:
when one of the 189 purely declarative files (a NestJS module, a constants file) first gains
That rule stays hard for the 246 logic-carrying files. A foreseeable friction case is different:
when one of the 193 purely declarative files (a NestJS module, a constants file) first gains
executable logic — for example a `useFactory` on a module — the function metric jumps from 0/0 to
0/N and the gate turns red. Tests remain the preferred fix, but unpinning that one file is an
allowed outcome if the PR description names and justifies it (not as a silent edit). For
Expand Down
14 changes: 14 additions & 0 deletions jest.coverage-gate.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const PINNED_LOGIC = [
'src/integration/blockchain/shared/enums/blockchain.enum.ts',
'src/integration/blockchain/shared/errors/tx-broadcast.error.ts',
'src/integration/blockchain/shared/evm/paymaster/pimlico-paymaster.service.ts',
'src/integration/blockchain/shared/services/blockchain-config-check.service.ts',
'src/integration/blockchain/zano/zano-helper.ts',
'src/integration/checkout/dto/checkout.dto.ts',
'src/integration/exchange/dto/mexc.dto.ts',
Expand All @@ -60,13 +61,19 @@ const PINNED_LOGIC = [
'src/integration/sift/dto/sift.dto.ts',
'src/polyfills.ts',
'src/shared/auth/allow-tfa-pending.decorator.ts',
'src/shared/auth/exceptions/staff-kyc-required.exception.ts',
'src/shared/auth/get-jwt.decorator.ts',
'src/shared/auth/role.guard.ts',
'src/shared/auth/staff-kyc-clearance.ts',
'src/shared/auth/user-role.enum.ts',
'src/shared/decorators/log-rejected-value.decorator.ts',
'src/shared/models/fiat/fiat.controller.ts',
'src/shared/pipes/detailed-validation.pipe.ts',
'src/shared/services/typeorm-logger.ts',
'src/shared/utils/bitbox-ascii.util.ts',
'src/shared/utils/cron.ts',
'src/shared/utils/custom-cron-expression.ts',
'src/shared/utils/request-caller.ts',
'src/shared/utils/request-client.ts',
'src/shared/validators/is-ssrf-safe-url.validator.ts',
'src/shared/validators/xor.validator.ts',
Expand Down Expand Up @@ -141,13 +148,15 @@ const PINNED_LOGIC = [
'src/subdomains/generic/user/models/user-data/kyc-identification-type.enum.ts',
'src/subdomains/generic/user/models/user-data/user-data.enum.ts',
'src/subdomains/generic/user/models/user/dto/verify-mail.dto.ts',
'src/subdomains/generic/user/models/user/staff-kyc-clearance.service.ts',
'src/subdomains/generic/user/models/user/user.enum.ts',
'src/subdomains/generic/user/services/webhook/dto/webhook.dto.ts',
'src/subdomains/supporting/bank-tx/bank-tx/dto/sepa.dto.ts',
'src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx-frick.service.ts',
'src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx-outgoing-match.service.ts',
'src/subdomains/supporting/bank/bank/dto/bank.dto.ts',
'src/subdomains/supporting/bank/bank/dto/receive-iban.enum.ts',
'src/subdomains/supporting/bank/virtual-iban/dto/virtual-iban.mapper.ts',
'src/subdomains/supporting/bank/virtual-iban/providers/frick-viban.provider.ts',
'src/subdomains/supporting/bank/virtual-iban/providers/viban-account-holder.enum.ts',
'src/subdomains/supporting/bank/virtual-iban/providers/yapeal-viban.provider.ts',
Expand All @@ -161,6 +170,7 @@ const PINNED_LOGIC = [
'src/subdomains/supporting/fiat-output/dto/create-fiat-output.dto.ts',
'src/subdomains/supporting/fiat-output/dto/update-fiat-output.dto.ts',
'src/subdomains/supporting/fiat-output/fiat-output-frick.service.ts',
'src/subdomains/supporting/log/client-error.controller.ts',
'src/subdomains/supporting/log/log.entity.ts',
'src/subdomains/supporting/notification/enums/index.ts',
'src/subdomains/supporting/payin/strategies/register/impl/base/polling.strategy.ts',
Expand Down Expand Up @@ -377,8 +387,11 @@ const PINNED_DECLARATIVE = [
'src/subdomains/core/liquidity-management/dto/output/liquidity-management-rule-output.dto.ts',
'src/subdomains/core/liquidity-management/dto/resolve-uncertain-order.dto.ts',
'src/subdomains/core/monitoring/system-state-snapshot.entity.ts',
'src/subdomains/core/payment-link/dto/assign-payment-link.dto.ts',
'src/subdomains/core/payment-link/dto/create-payment-merchant.dto.ts',
'src/subdomains/core/payment-link/dto/payment-link-recipient-address.dto.ts',
'src/subdomains/core/payment-link/dto/payment-link.dto.ts',
'src/subdomains/core/payment-link/dto/update-payment-link-payment.dto.ts',
'src/subdomains/core/payment-link/payment-link-payment.module.ts',
'src/subdomains/core/referral/process/ref.entity.ts',
'src/subdomains/core/referral/reward/dto/update-ref-reward.dto.ts',
Expand Down Expand Up @@ -437,6 +450,7 @@ const PINNED_DECLARATIVE = [
'src/subdomains/supporting/dex/dex.module.ts',
'src/subdomains/supporting/dex/strategies/sell-liquidity/impl/base/evm-coin.strategy.ts',
'src/subdomains/supporting/dex/strategies/sell-liquidity/impl/base/evm-token.strategy.ts',
'src/subdomains/supporting/log/dto/create-client-error.dto.ts',
'src/subdomains/supporting/log/dto/create-log.dto.ts',
'src/subdomains/supporting/log/log.module.ts',
'src/subdomains/supporting/notification/notification.module.ts',
Expand Down
16 changes: 13 additions & 3 deletions src/integration/bank/services/__tests__/frick.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ describe('BankFrickService', () => {
.filter((request) => request.url.endsWith('/accounts/0000000'));
expect(accountCalls).toHaveLength(2);
expect(accountCalls[0].data).toBe('');
// Standard WebAPI bodyless GETs must keep Content-Type: */* (vIBAN GETs deliberately omit it).
expect(accountCalls[0].headers['Content-Type']).toBe('*/*');
expect(accountCalls[0].headers.Authorization).toMatch(/^Bearer /);
expectSignature('', accountCalls[0].headers.Signature);
expect(http.request.mock.calls.filter(([request]) => request.url.endsWith('/authorize'))).toHaveLength(1);
Expand Down Expand Up @@ -1022,6 +1024,7 @@ describe('BankFrickService', () => {
expect(createRequest.url).toBe('https://vban.bank.invalid/vban/virtual-ibans');
expect(createRequest.method).toBe('POST');
expect(createRequest.data).toBe(JSON.stringify({ referenceAccountIban: debtorIban }));
expect(createRequest.headers['Content-Type']).toBe('application/json');
expectSignature(createRequest.data, createRequest.headers.Signature);
expect(createRequest.headers.Authorization).toMatch(/^Bearer /);
expect(createRequest.headers.algorithm).toBe('rsa-sha512');
Expand Down Expand Up @@ -1243,6 +1246,7 @@ describe('BankFrickService', () => {
expect(approveRequest.url).toBe('https://vban.bank.invalid/vban/virtual-ibans/activations/approvals');
expect(approveRequest.method).toBe('PUT');
expect(approveRequest.data).toBe(JSON.stringify({ vban: response.vban }));
expect(approveRequest.headers['Content-Type']).toBe('application/json');
expectSignature(approveRequest.data, approveRequest.headers.Signature);
expect(approveRequest.headers.Authorization).toMatch(/^Bearer /);
expect(approveRequest.headers.algorithm).toBe('rsa-sha512');
Expand All @@ -1265,6 +1269,8 @@ describe('BankFrickService', () => {
expect(getRequest.url).toBe(`https://vban.bank.invalid/vban/virtual-ibans/${encodeURIComponent(vbanWithSlash)}`);
expect(getRequest.method).toBe('GET');
expect(getRequest.data).toBe('');
// Bodyless vIBAN GETs must omit Content-Type: Azure gateway rejects Content-Type: */* with unsigned 403.
expect(getRequest.headers['Content-Type']).toBeUndefined();
expectSignature('', getRequest.headers.Signature);
});

Expand All @@ -1277,9 +1283,12 @@ describe('BankFrickService', () => {
http.request.mockResolvedValueOnce({ token: jwt() }).mockResolvedValueOnce(listResponse);

await expect(service.listVibans(undefined, undefined, 0, 50)).resolves.toEqual(listResponse);
expect(http.request.mock.calls[1][0].url).toBe(
'https://vban.bank.invalid/vban/virtual-ibans?pageIndex=0&pageSize=50',
);
const listRequest = http.request.mock.calls[1][0];
expect(listRequest.url).toBe('https://vban.bank.invalid/vban/virtual-ibans?pageIndex=0&pageSize=50');
// Bodyless vIBAN list GETs must omit Content-Type (unlike standard WebAPI GETs which send */*).
expect(listRequest.headers['Content-Type']).toBeUndefined();
expect(listRequest.data).toBe('');
expectSignature('', listRequest.headers.Signature);

http.request.mockResolvedValueOnce(listResponse);
await service.listVibans(debtorIban, [FrickVirtualIbanState.ACTIVE, FrickVirtualIbanState.PREPARED], 0, 50);
Expand All @@ -1289,6 +1298,7 @@ describe('BankFrickService', () => {
FrickVirtualIbanState.ACTIVE,
FrickVirtualIbanState.PREPARED,
]);
expect(http.request.mock.calls[2][0].headers['Content-Type']).toBeUndefined();

http.request.mockResolvedValueOnce({
pagination: { hasMore: false, pageIndex: 0, pageSize: 50, totalCount: 0 },
Expand Down
46 changes: 38 additions & 8 deletions src/integration/bank/services/frick.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ import { CamtTransaction, Iso20022Service } from './iso20022.service';

type FrickResponseType = 'json' | 'text';

/**
* Bodyless request Content-Type policy for signed Bank Frick calls.
* Required explicitly at every call site — no default — so WebAPI and vIBAN cannot silently share
* the wrong policy. WebAPI bodyless GETs send a wildcard Content-Type; vIBAN bodyless GETs must
* omit it entirely (production Azure gateway otherwise returns unsigned HTTP 403).
* Requests with a body always use `application/json` on both APIs and ignore this value.
*/
type FrickBodylessContentType = '*/*' | undefined;

/** A vIBAN create failure for which no Bank Frick object can have been created. */
export class FrickVibanNotCreatedError extends Error {}

Expand Down Expand Up @@ -776,6 +785,7 @@ export class BankFrickService {
responseType: FrickResponseType,
allowUnauthorizedRetry: boolean,
classifyVibanCreateFailure: boolean,
bodylessContentType: FrickBodylessContentType,
): Promise<T> {
this.assertAvailable();
let token: string;
Expand All @@ -793,6 +803,20 @@ export class BankFrickService {
throw error;
}

const requestHeaders: Record<string, string> = {
Accept: accept,
Authorization: `Bearer ${token}`,
Signature: signature,
algorithm: 'rsa-sha512',
};
// Bodyful requests are always application/json on WebAPI and vIBAN. Bodyless requests use the
// explicit API-specific policy: WebAPI sends Content-Type: */*; vIBAN omits the header entirely.
if (body === undefined) {
if (bodylessContentType !== undefined) requestHeaders['Content-Type'] = bodylessContentType;
} else {
requestHeaders['Content-Type'] = 'application/json';
}

try {
return await this.http.request<T>({
url,
Expand All @@ -801,13 +825,7 @@ export class BankFrickService {
responseType,
tryCount: 1,
timeout: BankFrickService.HTTP_TIMEOUT_MS,
headers: {
Accept: accept,
'Content-Type': body === undefined ? '*/*' : 'application/json',
Authorization: `Bearer ${token}`,
Signature: signature,
algorithm: 'rsa-sha512',
},
headers: requestHeaders,
responseVerifier: (rawBody, headers, status) => this.verifyResponse(rawBody, headers, status),
});
} catch (error) {
Expand All @@ -826,7 +844,17 @@ export class BankFrickService {
);
throw refreshError;
}
return this.requestSigned(url, path, method, body, accept, responseType, false, classifyVibanCreateFailure);
return this.requestSigned(
url,
path,
method,
body,
accept,
responseType,
false,
classifyVibanCreateFailure,
bodylessContentType,
);
}

const message = `Bank Frick API request failed (${method} ${this.sanitizeApiPathForError(path)}): ${this.getHttpFailureReason(error)}`;
Expand Down Expand Up @@ -854,6 +882,7 @@ export class BankFrickService {
responseType,
allowUnauthorizedRetry,
false,
'*/*',
);
}

Expand All @@ -876,6 +905,7 @@ export class BankFrickService {
responseType,
allowUnauthorizedRetry,
classifyVibanCreateFailure,
undefined,
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { createMock, DeepMocked } from '@golevelup/ts-jest';
import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum';
import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock';
import { AssetService } from 'src/shared/models/asset/asset.service';
import { BlockchainRegistryService } from '../../services/blockchain-registry.service';
import { EvmDecimalsService } from '../evm-decimals.service';

// The decimals it writes are read back through AssetService's cache. Writing past the service would
// leave that cache serving the previous rows, so these tests pin that the write goes through it.
describe('EvmDecimalsService.setDecimals', () => {
const usdt = createCustomAsset({ id: 7, dexName: 'USDT', blockchain: Blockchain.ETHEREUM });
const dai = createCustomAsset({ id: 9, dexName: 'DAI', blockchain: Blockchain.ETHEREUM });

let service: EvmDecimalsService;
let assetService: DeepMocked<AssetService>;
let blockchainRegistry: DeepMocked<BlockchainRegistryService>;
let getToken: jest.Mock;

beforeEach(() => {
assetService = createMock<AssetService>();
blockchainRegistry = createMock<BlockchainRegistryService>();

getToken = jest.fn().mockResolvedValue({ decimals: 6 });
blockchainRegistry.getEvmClient.mockReturnValue({ getToken } as never);

service = new EvmDecimalsService(assetService, blockchainRegistry);
});

it('writes the decimals through the service that owns the cache', async () => {
assetService.getEvmAssetsWithoutDecimals.mockResolvedValue([usdt]);

await service.setDecimals();

expect(assetService.updateAssets).toHaveBeenCalledWith([[7, { decimals: 6 }]]);
});

it('collects every asset into a single write', async () => {
assetService.getEvmAssetsWithoutDecimals.mockResolvedValue([usdt, dai]);

await service.setDecimals();

expect(assetService.updateAssets).toHaveBeenCalledTimes(1);
expect(assetService.updateAssets).toHaveBeenCalledWith([
[7, { decimals: 6 }],
[9, { decimals: 6 }],
]);
});

// The empty case is handed on rather than short-circuited here; AssetService.updateAssets is what
// decides that an empty list must not touch the cache.
it('collects nothing when no asset is missing its decimals', async () => {
assetService.getEvmAssetsWithoutDecimals.mockResolvedValue([]);

await service.setDecimals();

expect(assetService.updateAssets).toHaveBeenCalledWith([]);
});

it('keeps the assets it could read when one lookup fails', async () => {
assetService.getEvmAssetsWithoutDecimals.mockResolvedValue([usdt, dai]);
getToken.mockRejectedValueOnce(new Error('node unreachable')).mockResolvedValueOnce({ decimals: 18 });

await service.setDecimals();

expect(assetService.updateAssets).toHaveBeenCalledWith([[9, { decimals: 18 }]]);
});

it('collects nothing when every lookup fails', async () => {
assetService.getEvmAssetsWithoutDecimals.mockResolvedValue([usdt, dai]);
getToken.mockRejectedValue(new Error('node unreachable'));

await service.setDecimals();

expect(assetService.updateAssets).toHaveBeenCalledWith([]);
});
});
Loading
Loading