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
Expand Up @@ -5,7 +5,7 @@
* API Documentation: https://enablebanking.com/docs/api/reference
*/
import { t } from '@i18n/index';
import { BadRequestError, ForbiddenError } from '@js/errors';
import { BadGateway, BadRequestError, ForbiddenError } from '@js/errors';
import { logger } from '@js/utils/logger';
import axios, { AxiosInstance } from 'axios';

Expand Down Expand Up @@ -103,7 +103,9 @@ export class EnableBankingApiClient {
});
}

throw new Error(t({ key: 'bankDataProviders.enableBanking.apiGeneralError', variables: { message } }));
throw new BadGateway({
message: t({ key: 'bankDataProviders.enableBanking.apiGeneralError', variables: { message } }),
});
}

throw error;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ACCOUNT_STATUSES, BANK_PROVIDER_TYPE } from '@bt/shared/types';
import { afterEach, beforeEach, describe, expect, it } from '@jest/globals';
import { ERROR_CODES } from '@js/errors';
import BankDataProviderConnections from '@models/bank-data-provider-connections.model';
import * as helpers from '@tests/helpers';
import {
FixedTransaction,
Expand All @@ -12,6 +13,7 @@ import {
MOCK_IDENTIFICATION_HASH_2,
getAllMockAccountUIDs,
} from '@tests/mocks/enablebanking/data';
import { HttpResponse, http } from 'msw';

describe('Enable Banking Data Provider E2E', () => {
// Reset mock session counter before each test to ensure predictable behavior
Expand Down Expand Up @@ -258,7 +260,6 @@ describe('Enable Banking Data Provider E2E', () => {
});

// Access database model directly to check metadata
const BankDataProviderConnections = (await import('@models/bank-data-provider-connections.model')).default;
const connection = await BankDataProviderConnections.findByPk(result.connectionId);

// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -1722,4 +1723,244 @@ describe('Enable Banking Data Provider E2E', () => {
expect(txAfterThirdSync.length).toBe(1);
});
});

describe('403 session expiry handling', () => {
/**
* Helper to create a fully-active connection with one linked account.
* Returns connectionId and the linked system accountId.
*/
async function setupActiveConnection(): Promise<{ connectionId: number; accountId: number }> {
const connectResult = await helpers.bankDataProviders.connectProvider({
providerType: BANK_PROVIDER_TYPE.ENABLE_BANKING,
credentials: helpers.enablebanking.mockCredentials(),
raw: true,
});

const state = await helpers.enablebanking.getConnectionState(connectResult.connectionId);

await helpers.makeRequest({
method: 'post',
url: '/bank-data-providers/enablebanking/oauth-callback',
payload: {
connectionId: connectResult.connectionId,
code: helpers.enablebanking.mockAuthCode,
state,
},
});

const { syncedAccounts } = await helpers.bankDataProviders.connectSelectedAccounts({
connectionId: connectResult.connectionId,
accountExternalIds: [MOCK_IDENTIFICATION_HASH_1],
raw: true,
});

return {
connectionId: connectResult.connectionId,
accountId: syncedAccounts[0]!.id,
};
}

it('should mark connection as inactive when transactions API returns 403', async () => {
const { connectionId, accountId } = await setupActiveConnection();

// Verify connection is active before sync
const { connection: connectionBefore } = await helpers.bankDataProviders.getConnectionDetails({
connectionId,
raw: true,
});
expect(connectionBefore.isActive).toBe(true);

// Override transactions endpoint to return 403 (session expired)
global.mswMockServer.use(
http.get('https://api.enablebanking.com/accounts/:accountId/transactions', () => {
return new HttpResponse(JSON.stringify({ message: 'Session expired' }), { status: 403 });
}),
);

// Trigger sync — it will fail with ForbiddenError
const syncResult = await helpers.makeRequest({
method: 'post',
url: `/bank-data-providers/connections/${connectionId}/sync-transactions`,
payload: { accountId },
});

expect(syncResult.status).toEqual(ERROR_CODES.Forbidden);

// Connection must be marked inactive
const { connection: connectionAfter } = await helpers.bankDataProviders.getConnectionDetails({
connectionId,
raw: true,
});
expect(connectionAfter.isActive).toBe(false);
});

it('should set consentValidUntil to approximately current time when 403 occurs', async () => {
const { connectionId, accountId } = await setupActiveConnection();

// Verify consentValidUntil is a future date before sync
const connectionBefore = await BankDataProviderConnections.findByPk(connectionId);
const metadataBefore = connectionBefore!.metadata as { consentValidUntil: string };
expect(new Date(metadataBefore.consentValidUntil).getTime()).toBeGreaterThan(Date.now());

const syncStartedAt = new Date();

// Override transactions endpoint to return 403
global.mswMockServer.use(
http.get('https://api.enablebanking.com/accounts/:accountId/transactions', () => {
return new HttpResponse(JSON.stringify({ message: 'Session expired' }), { status: 403 });
}),
);

await helpers.makeRequest({
method: 'post',
url: `/bank-data-providers/connections/${connectionId}/sync-transactions`,
payload: { accountId },
});

// Verify consentValidUntil is now set to approximately the current time
const connectionAfter = await BankDataProviderConnections.findByPk(connectionId);
const metadataAfter = connectionAfter!.metadata as { consentValidUntil: string };
expect(metadataAfter.consentValidUntil).toBeDefined();

const consentValidUntil = new Date(metadataAfter.consentValidUntil);
// Should be at or after sync start, and not more than 500ms in the future
expect(consentValidUntil.getTime()).toBeGreaterThanOrEqual(syncStartedAt.getTime() - 1000);
expect(consentValidUntil.getTime()).toBeLessThanOrEqual(Date.now() + 500);
});

it('should mark connection as inactive when balance API returns 403', async () => {
const { connectionId, accountId } = await setupActiveConnection();

const syncStartedAt = new Date();

// Override balances endpoint to return 403 (transactions succeed, balance fails)
global.mswMockServer.use(
http.get('https://api.enablebanking.com/accounts/:accountId/balances', () => {
return new HttpResponse(JSON.stringify({ message: 'Session expired' }), { status: 403 });
}),
);

const syncResult = await helpers.makeRequest({
method: 'post',
url: `/bank-data-providers/connections/${connectionId}/sync-transactions`,
payload: { accountId },
});

expect(syncResult.status).toEqual(ERROR_CODES.Forbidden);

const { connection: connectionAfter } = await helpers.bankDataProviders.getConnectionDetails({
connectionId,
raw: true,
});
expect(connectionAfter.isActive).toBe(false);

// Verify consentValidUntil is reset to approximately current time (not a future consent date)
const dbConnection = await BankDataProviderConnections.findByPk(connectionId);
const metadata = dbConnection!.metadata as { consentValidUntil: string };
const consentValidUntil = new Date(metadata.consentValidUntil);
expect(consentValidUntil.getTime()).toBeGreaterThanOrEqual(syncStartedAt.getTime() - 1000);
expect(consentValidUntil.getTime()).toBeLessThanOrEqual(Date.now() + 500);
});

it('should not mark connection as inactive for non-403 errors', async () => {
const { connectionId, accountId } = await setupActiveConnection();

// Override transactions endpoint to return 500 (server error, not session expiry)
global.mswMockServer.use(
http.get('https://api.enablebanking.com/accounts/:accountId/transactions', () => {
return new HttpResponse(JSON.stringify({ message: 'Internal Server Error' }), { status: 500 });
}),
);

const syncResult = await helpers.makeRequest({
method: 'post',
url: `/bank-data-providers/connections/${connectionId}/sync-transactions`,
payload: { accountId },
});

// Sync should fail with bad gateway (external provider error, not a session expiry)
expect(syncResult.status).toEqual(ERROR_CODES.BadGateway);

// But connection should remain active (500 is not a session expiry)
const { connection: connectionAfter } = await helpers.bankDataProviders.getConnectionDetails({
connectionId,
raw: true,
});
expect(connectionAfter.isActive).toBe(true);
});

describe('fetchAccounts (listExternalAccounts endpoint)', () => {
it('should mark connection as inactive when session endpoint returns 403', async () => {
const { connectionId } = await setupActiveConnection();

global.mswMockServer.use(
http.get('https://api.enablebanking.com/sessions/:sessionId', () => {
return new HttpResponse(JSON.stringify({ message: 'Session expired' }), { status: 403 });
}),
);

const result = await helpers.bankDataProviders.listExternalAccounts({ connectionId });

expect(result.status).toEqual(ERROR_CODES.Forbidden);

const { connection } = await helpers.bankDataProviders.getConnectionDetails({ connectionId, raw: true });
expect(connection.isActive).toBe(false);
});

it('should mark connection as inactive when account details endpoint returns 403', async () => {
const { connectionId } = await setupActiveConnection();

global.mswMockServer.use(
http.get('https://api.enablebanking.com/accounts/:accountId/details', () => {
return new HttpResponse(JSON.stringify({ message: 'Session expired' }), { status: 403 });
}),
);

const result = await helpers.bankDataProviders.listExternalAccounts({ connectionId });

expect(result.status).toEqual(ERROR_CODES.Forbidden);

const { connection } = await helpers.bankDataProviders.getConnectionDetails({ connectionId, raw: true });
expect(connection.isActive).toBe(false);
});

it('should set consentValidUntil to current time when fetchAccounts gets 403', async () => {
const { connectionId } = await setupActiveConnection();

const syncStartedAt = new Date();

global.mswMockServer.use(
http.get('https://api.enablebanking.com/sessions/:sessionId', () => {
return new HttpResponse(JSON.stringify({ message: 'Session expired' }), { status: 403 });
}),
);

await helpers.bankDataProviders.listExternalAccounts({ connectionId });

const dbConnection = await BankDataProviderConnections.findByPk(connectionId);
const metadata = dbConnection!.metadata as { consentValidUntil: string };
const consentValidUntil = new Date(metadata.consentValidUntil);

expect(consentValidUntil.getTime()).toBeGreaterThanOrEqual(syncStartedAt.getTime() - 1000);
expect(consentValidUntil.getTime()).toBeLessThanOrEqual(Date.now() + 500);
});

it('should not mark connection as inactive for non-403 errors in fetchAccounts', async () => {
const { connectionId } = await setupActiveConnection();

global.mswMockServer.use(
http.get('https://api.enablebanking.com/sessions/:sessionId', () => {
return new HttpResponse(JSON.stringify({ message: 'Internal Server Error' }), { status: 500 });
}),
);

const result = await helpers.bankDataProviders.listExternalAccounts({ connectionId });

expect(result.status).toEqual(ERROR_CODES.BadGateway);

const { connection } = await helpers.bankDataProviders.getConnectionDetails({ connectionId, raw: true });
expect(connection.isActive).toBe(true);
});
});
});
});
Loading
Loading