From c982dc1008bc26157dc2d5d9719a3fb2e5231ba0 Mon Sep 17 00:00:00 2001 From: M4dotsuki <125310788+M4dotsuki@users.noreply.github.com> Date: Mon, 11 May 2026 09:19:06 +0900 Subject: [PATCH 1/3] Fix sandbox mode proxy gaps Signed-off-by: M4dotsuki <125310788+M4dotsuki@users.noreply.github.com> --- modules/investec.js | 2 +- package.json | 2 +- routes/investec.js | 10 ++++-- routes/investec/auth.js | 20 ++++++++++-- test/investec_sandbox.test.js | 59 +++++++++++++++++++++++++++++++++++ 5 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 test/investec_sandbox.test.js diff --git a/modules/investec.js b/modules/investec.js index 96a9f0a..19999ad 100644 --- a/modules/investec.js +++ b/modules/investec.js @@ -61,7 +61,7 @@ class Investec { } async get(path) { - await axios.get(path, this.axiosConfig()).catch(logError) + return await axios.get(path, this.axiosConfig()).catch(logError) } async getWithAuth(path) { diff --git a/package.json b/package.json index 8e92ba3..37bb339 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node --test" }, "keywords": [], "author": "", diff --git a/routes/investec.js b/routes/investec.js index e94919d..43cbab7 100644 --- a/routes/investec.js +++ b/routes/investec.js @@ -11,6 +11,10 @@ router.get('/za/v1/cards', async (_req, _res) => { const investec = new Investec(_req.currentUser.token) const response = await investec.getWithAuth(`/za/v1/cards`) + if (response.status >= 400) { + return _res.status(response.status).json(response.data) + } + let cards = response.data.data.cards // filter out any cards that aren't granted to this partition, @@ -31,7 +35,7 @@ router.get('/za/v1/cards', async (_req, _res) => { router.get('/*', async (_req, _res) => { const investec = new Investec(_req.currentUser.token) const response = await investec.getWithAuth(_req.url) - _res.json(response.data) + _res.status(response.status).json(response.data) }) // proxies any POST request to the investec adapter @@ -39,7 +43,7 @@ router.get('/*', async (_req, _res) => { router.post('/*', async (_req, _res) => { const investec = new Investec(_req.currentUser.token) const response = await investec.postWithAuth(_req.url, _req.body) - _res.json(response.data) + _res.status(response.status).json(response.data) }) -module.exports = router \ No newline at end of file +module.exports = router diff --git a/routes/investec/auth.js b/routes/investec/auth.js index 7a76fc1..4c31dbb 100644 --- a/routes/investec/auth.js +++ b/routes/investec/auth.js @@ -1,16 +1,32 @@ +function sandboxUser() { + return { token: 'SANDBOX', username: 'SANDBOX', partition: undefined } +} + function splitPartitionFromToken(partitionedToken) { // pulls out an optional partition (subfolder) from API credentials // which is used when filtering Investec API responses to show only // certain cards - const partitionedCredentials = new Buffer(partitionedToken, 'base64').toString() + if (partitionedToken === 'SANDBOX') { + return sandboxUser() + } + + const partitionedCredentials = Buffer.from(partitionedToken, 'base64').toString() + if (partitionedCredentials === 'SANDBOX') { + return sandboxUser() + } + const [partitionedUsername, password] = partitionedCredentials.split(":") const [username, partition] = partitionedUsername.split("/") - const token = (new Buffer.from(`${username}:${password}`)).toString('base64') + const token = Buffer.from(`${username}:${password}`).toString('base64') return { token, username, partition } } function getAuth(_req, _res, next) { + if (!_req.headers.authorization) { + return next() + } + let [authType, partitionedToken] = _req.headers.authorization.split(" ") if (authType == "Basic") { diff --git a/test/investec_sandbox.test.js b/test/investec_sandbox.test.js new file mode 100644 index 0000000..28e5c7d --- /dev/null +++ b/test/investec_sandbox.test.js @@ -0,0 +1,59 @@ +const assert = require('node:assert/strict') +const test = require('node:test') +const express = require('express') + +const Investec = require('../modules/investec') +const getAuth = require('../routes/investec/auth') +const investecRouter = require('../routes/investec') + +function currentUserForAuthorization(authorization) { + const req = { headers: { authorization } } + getAuth(req, {}, () => {}) + return req.currentUser +} + +test('auth middleware accepts raw Basic SANDBOX credentials', () => { + assert.deepEqual(currentUserForAuthorization('Basic SANDBOX'), { + token: 'SANDBOX', + username: 'SANDBOX', + partition: undefined + }) +}) + +test('Investec adapter returns sandbox accounts and transactions', async () => { + const investec = new Investec('SANDBOX') + + const accountsResponse = await investec.getWithAuth('/za/pb/v1/accounts') + assert.equal(accountsResponse.status, 200) + assert.equal(accountsResponse.data.data.accounts.length, 1) + + const accountId = accountsResponse.data.data.accounts[0].accountId + const transactionsResponse = await investec.getWithAuth(`/za/pb/v1/accounts/${accountId}/transactions`) + assert.equal(transactionsResponse.status, 200) + assert.equal(transactionsResponse.data.data.transactions.length, 2) + assert.ok(transactionsResponse.data.data.transactions.every((transaction) => transaction.accountId === accountId)) +}) + +test('Investec proxy preserves sandbox response statuses', async (t) => { + const app = express() + app.use(express.json()) + app.use('/investec', investecRouter) + + const server = app.listen(0) + t.after(() => server.close()) + + const { port } = server.address() + const baseUrl = `http://127.0.0.1:${port}/investec` + + const accountsResponse = await fetch(`${baseUrl}/za/pb/v1/accounts`, { + headers: { authorization: 'Basic SANDBOX' } + }) + assert.equal(accountsResponse.status, 200) + assert.equal((await accountsResponse.json()).data.accounts.length, 1) + + const missingResponse = await fetch(`${baseUrl}/za/pb/v1/not-implemented`, { + headers: { authorization: 'Basic SANDBOX' } + }) + assert.equal(missingResponse.status, 404) + assert.equal((await missingResponse.json()).data.path, '/za/pb/v1/not-implemented') +}) From 7bd498d53846f5a3a9fa903fa5835dcebaed5cab Mon Sep 17 00:00:00 2001 From: M4dotsuki <125310788+M4dotsuki@users.noreply.github.com> Date: Mon, 11 May 2026 09:28:33 +0900 Subject: [PATCH 2/3] Tighten sandbox proxy status handling Signed-off-by: M4dotsuki <125310788+M4dotsuki@users.noreply.github.com> --- routes/investec.js | 6 +++--- routes/investec/auth.js | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/routes/investec.js b/routes/investec.js index 43cbab7..3f63597 100644 --- a/routes/investec.js +++ b/routes/investec.js @@ -11,7 +11,7 @@ router.get('/za/v1/cards', async (_req, _res) => { const investec = new Investec(_req.currentUser.token) const response = await investec.getWithAuth(`/za/v1/cards`) - if (response.status >= 400) { + if ((response.status || 200) >= 400) { return _res.status(response.status).json(response.data) } @@ -35,7 +35,7 @@ router.get('/za/v1/cards', async (_req, _res) => { router.get('/*', async (_req, _res) => { const investec = new Investec(_req.currentUser.token) const response = await investec.getWithAuth(_req.url) - _res.status(response.status).json(response.data) + _res.status(response.status || 200).json(response.data) }) // proxies any POST request to the investec adapter @@ -43,7 +43,7 @@ router.get('/*', async (_req, _res) => { router.post('/*', async (_req, _res) => { const investec = new Investec(_req.currentUser.token) const response = await investec.postWithAuth(_req.url, _req.body) - _res.status(response.status).json(response.data) + _res.status(response.status || 200).json(response.data) }) module.exports = router diff --git a/routes/investec/auth.js b/routes/investec/auth.js index 4c31dbb..cb2b827 100644 --- a/routes/investec/auth.js +++ b/routes/investec/auth.js @@ -32,7 +32,6 @@ function getAuth(_req, _res, next) { if (authType == "Basic") { let { token, username, partition } = splitPartitionFromToken(partitionedToken) _req.currentUser = { username, partition, token } - console.log(_req.currentUser) } if (authType == "RootCard") { From c534685c567dc55835b0d5987db73088e252e17a Mon Sep 17 00:00:00 2001 From: M4dotsuki <125310788+M4dotsuki@users.noreply.github.com> Date: Mon, 11 May 2026 12:58:02 +0900 Subject: [PATCH 3/3] Expand Investec sandbox endpoint coverage Signed-off-by: M4dotsuki <125310788+M4dotsuki@users.noreply.github.com> --- modules/investec_sandbox.js | 51 ++++++++++++++++++++++++++++++++--- readme.md | 12 +++++++++ test/investec_sandbox.test.js | 36 ++++++++++++++++++++++++- 3 files changed, 95 insertions(+), 4 deletions(-) diff --git a/modules/investec_sandbox.js b/modules/investec_sandbox.js index 10366ce..4c3224a 100644 --- a/modules/investec_sandbox.js +++ b/modules/investec_sandbox.js @@ -42,22 +42,67 @@ const transactions = [ } ] +const balance = { + accountId, + currentBalance: 125500, + availableBalance: 125500, + currency: 'ZAR' +} + function response(data, status = 200) { return { status, data } } +function sandboxUrl(path) { + return new URL(path, 'https://sandbox.banking.make') +} + +function matchesDateRange(transaction, fromDate, toDate) { + const date = transaction.transactionDate || transaction.postingDate + return (!fromDate || date >= fromDate) && (!toDate || date <= toDate) +} + +function filterTransactions(requestedAccountId, params) { + const fromDate = params.get('fromDate') + const toDate = params.get('toDate') + const transactionType = params.get('transactionType') + + return transactions.filter((transaction) => { + return transaction.accountId === requestedAccountId && + matchesDateRange(transaction, fromDate, toDate) && + (!transactionType || transaction.transactionType === transactionType) + }) +} + class InvestecSandbox { async getWithAuth(path) { - if (path === '/za/pb/v1/accounts') { + const url = sandboxUrl(path) + + if (url.pathname === '/za/pb/v1/accounts') { return response({ data: { accounts } }) } - const transactionsMatch = path.match(/^\/za\/pb\/v1\/accounts\/([^/]+)\/transactions/) + const balanceMatch = url.pathname.match(/^\/za\/pb\/v1\/accounts\/([^/]+)\/balance$/) + if (balanceMatch) { + const requestedAccountId = balanceMatch[1] + if (requestedAccountId !== accountId) { + return response({ + data: { + message: 'Sandbox account not found', + accountId: requestedAccountId + } + }, 404) + } + + return response({ data: balance }) + } + + const transactionsMatch = url.pathname.match(/^\/za\/pb\/v1\/accounts\/([^/]+)\/transactions$/) if (transactionsMatch) { const requestedAccountId = transactionsMatch[1] return response({ data: { - transactions: transactions.filter(transaction => transaction.accountId === requestedAccountId) + transactions: filterTransactions(requestedAccountId, url.searchParams) } }) } diff --git a/readme.md b/readme.md index 28e2f5f..54c945a 100644 --- a/readme.md +++ b/readme.md @@ -61,6 +61,18 @@ __Where do I get support?__ - `routes/investec/auth.js` contains middleware to extract partition information from API keys, if the requester is using partitioned access and `routes/investec/card_partitions.js` contains config for those partitions - `routes/investec/special.js` extends Investec API base functionality by providing an abstraction layer for card control, handling card events, and simplifying the transfer endpoint +#### Sandbox mode + +Use `SANDBOX` Basic auth credentials to exercise Investec account endpoints without calling the live Investec API. This lets clients switch between live and sandbox mode by changing credentials while keeping the same API paths. + +Sandbox mode currently supports: + +- `GET /investec/za/pb/v1/accounts` +- `GET /investec/za/pb/v1/accounts/sandbox-account-1/balance` +- `GET /investec/za/pb/v1/accounts/sandbox-account-1/transactions` + +The transactions endpoint also accepts the optional `fromDate`, `toDate`, and `transactionType` query parameters. + ### RootCode card control - `modules/root_code_card_policy.js` converts simple config flags into a JS bundle that can be compiled as RootCode onto a supported card diff --git a/test/investec_sandbox.test.js b/test/investec_sandbox.test.js index 28e5c7d..568aa70 100644 --- a/test/investec_sandbox.test.js +++ b/test/investec_sandbox.test.js @@ -20,7 +20,7 @@ test('auth middleware accepts raw Basic SANDBOX credentials', () => { }) }) -test('Investec adapter returns sandbox accounts and transactions', async () => { +test('Investec adapter returns sandbox accounts, balance, and transactions', async () => { const investec = new Investec('SANDBOX') const accountsResponse = await investec.getWithAuth('/za/pb/v1/accounts') @@ -28,10 +28,28 @@ test('Investec adapter returns sandbox accounts and transactions', async () => { assert.equal(accountsResponse.data.data.accounts.length, 1) const accountId = accountsResponse.data.data.accounts[0].accountId + const balanceResponse = await investec.getWithAuth(`/za/pb/v1/accounts/${accountId}/balance`) + assert.equal(balanceResponse.status, 200) + assert.deepEqual(balanceResponse.data.data, { + accountId, + currentBalance: 125500, + availableBalance: 125500, + currency: 'ZAR' + }) + const transactionsResponse = await investec.getWithAuth(`/za/pb/v1/accounts/${accountId}/transactions`) assert.equal(transactionsResponse.status, 200) assert.equal(transactionsResponse.data.data.transactions.length, 2) assert.ok(transactionsResponse.data.data.transactions.every((transaction) => transaction.accountId === accountId)) + + const filteredResponse = await investec.getWithAuth( + `/za/pb/v1/accounts/${accountId}/transactions?fromDate=2024-01-10&transactionType=CardPurchases` + ) + assert.equal(filteredResponse.status, 200) + assert.deepEqual( + filteredResponse.data.data.transactions.map((transaction) => transaction.description), + ['SANDBOX COFFEE SHOP'] + ) }) test('Investec proxy preserves sandbox response statuses', async (t) => { @@ -51,6 +69,22 @@ test('Investec proxy preserves sandbox response statuses', async (t) => { assert.equal(accountsResponse.status, 200) assert.equal((await accountsResponse.json()).data.accounts.length, 1) + const balanceResponse = await fetch(`${baseUrl}/za/pb/v1/accounts/sandbox-account-1/balance`, { + headers: { authorization: 'Basic SANDBOX' } + }) + assert.equal(balanceResponse.status, 200) + assert.equal((await balanceResponse.json()).data.currency, 'ZAR') + + const filteredTransactionsResponse = await fetch( + `${baseUrl}/za/pb/v1/accounts/sandbox-account-1/transactions?fromDate=2024-01-10&transactionType=CardPurchases`, + { headers: { authorization: 'Basic SANDBOX' } } + ) + assert.equal(filteredTransactionsResponse.status, 200) + assert.deepEqual( + (await filteredTransactionsResponse.json()).data.transactions.map((transaction) => transaction.description), + ['SANDBOX COFFEE SHOP'] + ) + const missingResponse = await fetch(`${baseUrl}/za/pb/v1/not-implemented`, { headers: { authorization: 'Basic SANDBOX' } })