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/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/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/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/routes/investec.js b/routes/investec.js index e94919d..3f63597 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 || 200) >= 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 || 200).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 || 200).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..cb2b827 100644 --- a/routes/investec/auth.js +++ b/routes/investec/auth.js @@ -1,22 +1,37 @@ +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") { let { token, username, partition } = splitPartitionFromToken(partitionedToken) _req.currentUser = { username, partition, token } - console.log(_req.currentUser) } if (authType == "RootCard") { diff --git a/test/investec_sandbox.test.js b/test/investec_sandbox.test.js new file mode 100644 index 0000000..568aa70 --- /dev/null +++ b/test/investec_sandbox.test.js @@ -0,0 +1,93 @@ +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, balance, 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 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) => { + 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 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' } + }) + assert.equal(missingResponse.status, 404) + assert.equal((await missingResponse.json()).data.path, '/za/pb/v1/not-implemented') +})