diff --git a/src/plugins/mtbank/__tests__/index.test.js b/src/plugins/mtbank/__tests__/index.test.js index e5c6e5ad0..3f4024ff3 100644 --- a/src/plugins/mtbank/__tests__/index.test.js +++ b/src/plugins/mtbank/__tests__/index.test.js @@ -2,6 +2,10 @@ import fetchMock from 'fetch-mock' import { scrape } from '..' describe('scrape', () => { + afterEach(() => { + fetchMock.restore() + }) + it('should hit the mocks and return results', async () => { mockIdentity() mockCheckPassword() @@ -85,6 +89,342 @@ describe('scrape', () => { comment: 'Возврат денежных средств' }]) }) + + it('should keep cookies fresh for userRole before parallel statements', async () => { + const seenCookies = { + checkPassword: [], + userRole: [], + loadUser: [], + statements: [] + } + + fetchMock.once({ + method: 'POST', + matcher: (url) => url === 'https://mybank.by/api/v1/login/userIdentityByPhone', + response: { + status: 200, + body: JSON.stringify({ + ResponseType: 'ResponseOfUserIdentityByPhoneData', + error: null, + sessionId: null, + success: true, + validateErrors: null, + data: { + smsCode: null, + userLinkedAbs: true + } + }), + statusText: 'OK', + headers: { 'set-cookie': 'identity-cookie=1' }, + sendAsJson: false + } + }) + + fetchMock.once({ + method: 'POST', + matcher: (url, { headers }) => { + if (url !== 'https://mybank.by/api/v1/login/checkPassword4') return false + seenCookies.checkPassword.push(headers.Cookie) + return true + }, + response: { + status: 200, + body: JSON.stringify({ + ResponseType: 'ResponseOfCheckPasswordResponse', + error: null, + sessionId: null, + success: true, + validateErrors: null, + data: { + fingerToken: null, + nextOperation: null, + userInfo: { + email: 'TEST@GMAIL.COM', + firstName: 'Вася', + middleName: 'Петрович', + isCustomLogin: false, + lastName: 'Осюк', + login: 'login', + phone: '', + isResident: true, + dboContracts: [{ + contractNum: 'IB_I/123456', + status: 'REGISTERED', + role: 'F', + isAdmin: null, + name: 'Осюк В.П.', + longname: 'Осюк Вася Петрович', + smsConfirmation: false + }] + } + } + }), + statusText: 'OK', + headers: { 'set-cookie': 'password-cookie=1' }, + sendAsJson: false + } + }) + + fetchMock.once({ + method: 'POST', + matcher: (url, { headers }) => { + if (url !== 'https://mybank.by/api/v1/user/userRole') return false + seenCookies.userRole.push(headers.Cookie) + return true + }, + response: { + status: 200, + body: JSON.stringify({ + ResponseType: 'ResponseOfstring', + error: null, + sessionId: null, + success: true, + validateErrors: null, + data: null + }), + statusText: 'OK', + headers: { 'set-cookie': 'role-cookie=1' }, + sendAsJson: false + } + }) + + fetchMock.once({ + method: 'GET', + matcher: (url, { headers }) => { + if (url !== 'https://mybank.by/api/v1/user/loadUser') return false + seenCookies.loadUser.push(headers.Cookie) + return true + }, + response: { + status: 200, + body: JSON.stringify({ + ResponseType: 'ResponseOfUserResponse', + error: null, + sessionId: '1111111111111', + success: true, + validateErrors: null, + data: { + addInfo: [], + products: [{ + productType: 'PC', + accountId: '1111111', + id: 1, + productCode: '1111111|PC', + show: true, + variationId: '[MASTERCARD][MCW INSTANT BYR 3Y (PAYOKAY)]', + accruedInterest: null, + avlBalance: '999.9', + avlLimit: null, + cardAccounts: [{ + accType: null, + accountId: 'BY36MTBK10110001000001111000', + accountIdenType: 'PC', + availableBalance: null, + contractCode: '1111111', + currencyCode: 'BYN', + productType: 'PC' + }], + cardContract: [{ + productType: 'MTBANK_CARD', + closeDate: '2020-01-31', + contractNum: '11111111', + openDate: '2018-01-31', + cardTerm: '11/11', + rateBal: '0.0001', + servicePaySum: null, + servicePayTerm: null, + smsNotification: '1', + tariffPlan: 'PayOkay' + }], + cards: [{ + blockReason: null, + cardCurr: 'BYN', + commisDate: '2011-01-31', + commisSum: '1.1', + description: 'MASTERCARD', + embossedName: 'ANDREI IOKSHA', + isOfAccntHolder: '1', + limits: [], + mainCard: '1', + over: '0', + pan: '111111_1111', + smsNotification: '1', + status: 'A', + term: '11/11', + type: 'MASTERCARD', + vpan: '1111111111111111', + rbs: '1111111', + pinPhoneNumber: '111111111111' + }], + debtPayment: null, + debtPaymentSumCom: null, + description: 'PayOkay', + gracePeriodAvalDays: null, + gracePeriodEnd: null, + gracePeriodLength: null, + gracePeriodOutRateCashless: null, + gracePeriodRateCashless: null, + gracePeriodStart: null, + isActive: true, + isOverdraft: false, + loanContractDate: null, + loanContractNumber: null, + loanNextPaymentAmmount: null, + loanNextPaymentDate: null, + minPaymentFee: null, + minPaymentMainDept: null, + minPaymentOverFee: null, + minPaymentOverMainDept: null, + minPaymentOverPer: null, + minPaymentPenalty: null, + minPaymentPer: null, + minPaymentStandardOper: null, + minPaymentStandardOperPer: null, + minPaymentStateDue: null, + minPaymentUBS: null, + over: null, + overStandardOperationRate: null, + overdueDebts: null, + ownFunds: null, + points: '', + pointsDate: null, + productIdenType: null, + rate: '0.0001', + rateAvalInstalment: null, + rateAvalInstalmentHistory: [], + rateCache: null, + rateCacheHistory: [], + rateCacheless: null, + rateCachelessHistory: [], + rateChangingHistory: [], + rateExpirPayment: null, + rateExpirPaymentHistory: [], + standardOperationRate: null + }], + userEripId: null, + userInfo: { + crmId: '1111111111', + email: '', + firstName: 'Иван', + isCustomLogin: false, + lastName: 'Иванов', + login: '1111111111111111111111111111111111', + phone: '111111111111', + isResident: true + } + } + }), + statusText: 'OK', + headers: { 'set-cookie': 'load-user-cookie=1' }, + sendAsJson: false + } + }) + + fetchMock.once({ + method: 'POST', + matcher: (url, { headers }) => { + if (url !== 'https://mybank.by/api/v1/product/loadOperationStatements') return false + seenCookies.statements.push(headers.Cookie) + return true + }, + response: { + status: 200, + body: JSON.stringify({ + ResponseType: 'ResponseOfListOfTransactionStatement', + error: null, + sessionId: null, + success: true, + validateErrors: null, + data: [{ + accountCurr: 'BYN', + accountId: 'BY36MTBK10110001000001111000', + avlBalance: '791.3', + client: 'Иванов Иван Иванович', + dateFrom: '2018-12-27', + dateTo: '2019-01-16', + incomingBalance: '999.9', + numDateContract: '11111111 от 1111.11.11', + operations: [{ + amount: '29.68', + balance: '531.57', + cardPan: '111111******1111', + curr: 'BYN', + debitFlag: '0', + description: 'Оплата товаров и услуг', + error: '', + operationDate: '2019-01-02', + orderStatus: '1', + place: 'Магазин', + status: 'T', + transAmount: '29.68', + transDate: '2018-12-29 01:07:39', + transactionId: '1111112' + }], + outgoingBalance: '999.9', + receivedAmount: '999.9', + time: '2019-01-16 17:30:49', + writtenOffAmount: '999.9' + }] + }), + statusText: 'OK', + headers: { 'set-cookie': 'statement-cookie-1=1' }, + sendAsJson: false + } + }) + + fetchMock.once({ + method: 'POST', + matcher: (url, { headers }) => { + if (url !== 'https://mybank.by/api/v1/product/loadOperationStatements') return false + seenCookies.statements.push(headers.Cookie) + return true + }, + response: { + status: 200, + body: JSON.stringify({ + ResponseType: 'ResponseOfListOfTransactionStatement', + error: null, + sessionId: null, + success: true, + validateErrors: null, + data: [{ + accountCurr: 'BYN', + accountId: 'BY36MTBK10110001000001111000', + avlBalance: '791.3', + client: 'Иванов Иван Иванович', + dateFrom: '2019-01-16', + dateTo: '2019-01-18', + incomingBalance: '999.9', + numDateContract: '11111111 от 1111.11.11', + operations: [], + outgoingBalance: '999.9', + receivedAmount: '999.9', + time: '2019-01-18 17:30:49', + writtenOffAmount: '999.9' + }] + }), + statusText: 'OK', + headers: { 'set-cookie': 'statement-cookie-2=1' }, + sendAsJson: false + } + }) + + const result = await scrape({ + preferences: { phone: '123456789', password: 'pass' }, + fromDate: new Date('2018-12-27T00:00:00.000+03:00'), + toDate: new Date('2019-01-18T00:00:00.000+03:00') + }) + + expect(result.accounts).toHaveLength(1) + expect(result.transactions).toHaveLength(1) + expect(seenCookies.checkPassword).toEqual(['identity-cookie=1']) + expect(seenCookies.userRole).toEqual(['identity-cookie=1; password-cookie=1']) + expect(seenCookies.loadUser).toEqual(['identity-cookie=1; password-cookie=1; role-cookie=1']) + expect(seenCookies.statements).toEqual([ + 'identity-cookie=1; password-cookie=1; role-cookie=1; load-user-cookie=1', + 'identity-cookie=1; password-cookie=1; role-cookie=1; load-user-cookie=1' + ]) + }) }) function mockLoadOperationStatements () { diff --git a/src/plugins/mtbank/api.js b/src/plugins/mtbank/api.js index 32390d4ed..4bdadc379 100644 --- a/src/plugins/mtbank/api.js +++ b/src/plugins/mtbank/api.js @@ -1,7 +1,7 @@ import { defaultsDeep, flatMap } from 'lodash' import { createDateIntervals as commonCreateDateIntervals } from '../../common/dateUtils' import { fetchJson } from '../../common/network' -import { BankMessageError, InvalidOtpCodeError } from '../../errors' +import { BankMessageError, InvalidOtpCodeError, TemporaryError } from '../../errors' import { getDate } from './converters' const baseUrl = 'https://mybank.by/api/v1/' @@ -77,9 +77,10 @@ async function fetchApiJson (url, options, predicate = () => true, error = (mess return response } -function validateResponse (response, predicate, error = (message) => console.assert(false, message)) { +function validateResponse (response, predicate, error = (message) => new TemporaryError(message)) { if (!predicate || !predicate(response)) { - error('non-successful response') + const maybeError = error('non-successful response') + throw maybeError !== undefined ? maybeError : new TemporaryError('non-successful response') } } @@ -107,19 +108,41 @@ function parseCookies (response) { const mapToCookieHeader = (map) => [...map.entries()].map(([key, value]) => `${key}=${value}`).join('; ') +function updateCookies (sessionCookies, response) { + if (!response || !response.headers) return + + for (const [key, value] of parseCookies(response).entries()) { + sessionCookies.set(key, value) + } +} + +async function fetchApiJsonWithSessionCookies (sessionCookies, url, options, predicate, error) { + const response = await fetchApiJson(url, { + ...options, + headers: { + ...options?.headers, + Cookie: mapToCookieHeader(sessionCookies) + } + }, predicate, error) + updateCookies(sessionCookies, response) + return response +} + export async function login (login, password) { + const cookies = new Map() + let res = await fetchApiJson('login/userIdentityByPhone', { method: 'POST', body: { phoneNumber: login, loginWay: '1' }, sanitizeRequestLog: { body: { phoneNumber: true } } }, response => response.body.success) - const cookies = parseCookies(res) + updateCookies(cookies, res) - res = await fetchApiJson( + res = await fetchApiJsonWithSessionCookies( + cookies, 'login/checkPassword4', { method: 'POST', - headers: { Cookie: mapToCookieHeader(cookies) }, body: { password, version: '2.1.18' }, sanitizeRequestLog: { body: { password: true } }, sanitizeResponseLog: { @@ -144,11 +167,11 @@ export async function login (login, password) { if (!smsCode) { throw new InvalidOtpCodeError() } - await fetchApiJson( + await fetchApiJsonWithSessionCookies( + cookies, 'login/checkSms', { method: 'POST', - headers: { Cookie: mapToCookieHeader(cookies) }, body: { smsCode }, sanitizeRequestLog: { body: { smsCode: true } } }, @@ -156,14 +179,16 @@ export async function login (login, password) { ) } - await fetchApiJson( + await fetchApiJsonWithSessionCookies( + cookies, 'user/userRole', { method: 'POST', body: res.body.data.userInfo.dboContracts[0], sanitizeRequestLog: { body: true } }, - (response) => response.body.success + (response) => response.body.success, + (message) => new TemporaryError(message) ) return cookies @@ -172,15 +197,8 @@ export async function login (login, password) { export async function fetchAccounts (sessionCookies) { console.log('>>> Загрузка списка счетов...') - const response = await fetchApiJson('user/loadUser', { - headers: { Cookie: mapToCookieHeader(sessionCookies) } - }, response => response.body && response.body.data && response.body.data.products, - message => new TemporaryError(message)) - - // Update session cookies with new values - for (const [key, value] of parseCookies(response).entries()) { - sessionCookies.set(key, value) - } + const response = await fetchApiJsonWithSessionCookies(sessionCookies, 'user/loadUser', {}, response => response.body && response.body.data && response.body.data.products, + message => new TemporaryError(message)) return response.body.data.products } @@ -208,9 +226,8 @@ export async function fetchTransactions (sessionCookies, accounts, fromDate, toD for (const account of accounts) { const responses = await Promise.all(intervals.map(([startDate, endDate]) => { - return fetchApiJson('product/loadOperationStatements', { + return fetchApiJsonWithSessionCookies(sessionCookies, 'product/loadOperationStatements', { method: 'POST', - headers: { Cookie: mapToCookieHeader(sessionCookies) }, body: { contractCode: account.id, accountIdenType: account.productType,