From f24b65dfab28c6186ced792651cbfe5d86fc89c7 Mon Sep 17 00:00:00 2001 From: Andrei Takarski Date: Thu, 31 Oct 2024 12:48:43 +0300 Subject: [PATCH] feat(*): add server logic for detect webview --- src/{ => client}/bridge-to-native.ts | 0 src/{ => client}/constants.ts | 0 src/{ => client}/native-fallbacks.ts | 0 .../native-navigation-and-title.ts | 0 src/{ => client}/types.ts | 0 src/{ => client}/utils.ts | 0 src/index.ts | 4 +- src/server/check-is-webview.ts | 36 ++ src/server/constants.ts | 13 + .../detect-and-extract-native-params.ts | 92 ++++ ...xtract-and-join-original-webview-params.ts | 42 ++ src/server/index.ts | 5 + src/server/reg-exp-patterns.ts | 9 + src/server/types.ts | 16 + src/server/utils.ts | 59 +++ test/{ => client}/constants.test.ts | 2 +- test/{ => client}/index.test.ts | 10 +- test/{ => client}/integration.test.ts | 2 +- .../{ => client}/mock/mock-session-storage.ts | 0 test/{ => client}/native-fallbacks.test.ts | 10 +- .../native-navigation-and-title.test.ts | 8 +- test/{ => client}/utils.test.ts | 4 +- test/server/check-is-webview.test.ts | 124 +++++ .../detect-and-extract-native-params.test.ts | 423 ++++++++++++++++++ ...t-and-join-original-webview-params.test.ts | 45 ++ test/server/utils.test.ts | 46 ++ 26 files changed, 930 insertions(+), 20 deletions(-) rename src/{ => client}/bridge-to-native.ts (100%) rename src/{ => client}/constants.ts (100%) rename src/{ => client}/native-fallbacks.ts (100%) rename src/{ => client}/native-navigation-and-title.ts (100%) rename src/{ => client}/types.ts (100%) rename src/{ => client}/utils.ts (100%) create mode 100644 src/server/check-is-webview.ts create mode 100644 src/server/constants.ts create mode 100644 src/server/detect-and-extract-native-params.ts create mode 100644 src/server/extract-and-join-original-webview-params.ts create mode 100644 src/server/index.ts create mode 100644 src/server/reg-exp-patterns.ts create mode 100644 src/server/types.ts create mode 100644 src/server/utils.ts rename test/{ => client}/constants.test.ts (98%) rename test/{ => client}/index.test.ts (98%) rename test/{ => client}/integration.test.ts (99%) rename test/{ => client}/mock/mock-session-storage.ts (100%) rename test/{ => client}/native-fallbacks.test.ts (97%) rename test/{ => client}/native-navigation-and-title.test.ts (99%) rename test/{ => client}/utils.test.ts (96%) create mode 100644 test/server/check-is-webview.test.ts create mode 100644 test/server/detect-and-extract-native-params.test.ts create mode 100644 test/server/extract-and-join-original-webview-params.test.ts create mode 100644 test/server/utils.test.ts diff --git a/src/bridge-to-native.ts b/src/client/bridge-to-native.ts similarity index 100% rename from src/bridge-to-native.ts rename to src/client/bridge-to-native.ts diff --git a/src/constants.ts b/src/client/constants.ts similarity index 100% rename from src/constants.ts rename to src/client/constants.ts diff --git a/src/native-fallbacks.ts b/src/client/native-fallbacks.ts similarity index 100% rename from src/native-fallbacks.ts rename to src/client/native-fallbacks.ts diff --git a/src/native-navigation-and-title.ts b/src/client/native-navigation-and-title.ts similarity index 100% rename from src/native-navigation-and-title.ts rename to src/client/native-navigation-and-title.ts diff --git a/src/types.ts b/src/client/types.ts similarity index 100% rename from src/types.ts rename to src/client/types.ts diff --git a/src/utils.ts b/src/client/utils.ts similarity index 100% rename from src/utils.ts rename to src/client/utils.ts diff --git a/src/index.ts b/src/index.ts index 683d027..932b645 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,2 @@ -export { BridgeToNative } from './bridge-to-native'; -export { NativeParams, Theme, Environment, NativeFeatureKey, PdfType } from './types'; +export { BridgeToNative } from './client/bridge-to-native'; +export { NativeParams, Theme, Environment, NativeFeatureKey, PdfType } from './client/types'; diff --git a/src/server/check-is-webview.ts b/src/server/check-is-webview.ts new file mode 100644 index 0000000..6bbc8ae --- /dev/null +++ b/src/server/check-is-webview.ts @@ -0,0 +1,36 @@ +import { + extractAppVersion, + extractUserAgent, + isAkeyWebview, + extractNativeParamsFromCookies +} from './utils'; +import { RequestHeaderType } from './types'; +import { versionPattern, webviewUaIOSPattern } from './reg-exp-patterns'; + +export const isWebviewByUserAgent = ( + userAgent: string, + appVersion: string | undefined, +) => { + if (userAgent && isAkeyWebview(userAgent)) { + return false; + } + + return ( + (appVersion && versionPattern.test(appVersion)) || !!userAgent?.match(webviewUaIOSPattern) + ); +}; + +export const isWebviewByCookies = (nativeParamsFromCookies: Record | null) => { + return !!(nativeParamsFromCookies && nativeParamsFromCookies.isWebview) +} +export const checkIsWebview = ( + request: RequestHeaderType, +): boolean => { + const userAgent = extractUserAgent(request); + + // `app-version` в заголовках – индикатор вебвью. В iOS есть только в первом запросе от webview + const appVersion = extractAppVersion(request); + const nativeParamsFromCookies = extractNativeParamsFromCookies(request); + + return isWebviewByCookies(nativeParamsFromCookies) || isWebviewByUserAgent(userAgent, appVersion); +}; diff --git a/src/server/constants.ts b/src/server/constants.ts new file mode 100644 index 0000000..9827233 --- /dev/null +++ b/src/server/constants.ts @@ -0,0 +1,13 @@ +export const THEME_QUERY = 'theme'; +export const TITLE = 'title'; + +export const WEBVIEW_IOS_APP_ID_QUERY = 'applicationId'; +export const WEBVIEW_IOS_APP_VERSION_QUERY = 'device_app_version'; +export const WEBVIEW_WITHOUT_LAYOUT_QUERY = 'without_layout'; +export const WEBVIEW_NEXT_PAGE_ID_QUERY = 'nextPageId'; + +export const CAPSULE_UA_SUBSTR = 'Capsule'; +export const AKEY_UA_SUBSTR = 'AKEY'; +export const VOSKHOD_UA_SUBSTR = 'VOSKHOD'; + +export const NATIVE_PARAMS_COOKIE_NAME = 'app_native_params'; diff --git a/src/server/detect-and-extract-native-params.ts b/src/server/detect-and-extract-native-params.ts new file mode 100644 index 0000000..d1344f8 --- /dev/null +++ b/src/server/detect-and-extract-native-params.ts @@ -0,0 +1,92 @@ +import { + THEME_QUERY, + TITLE, + WEBVIEW_IOS_APP_ID_QUERY, + WEBVIEW_IOS_APP_VERSION_QUERY, + WEBVIEW_NEXT_PAGE_ID_QUERY, + WEBVIEW_WITHOUT_LAYOUT_QUERY, + NATIVE_PARAMS_COOKIE_NAME +} from './constants'; + +import { extractAppVersion } from './utils'; + +import { extractAndJoinOriginalWebviewParams } from './extract-and-join-original-webview-params'; +import { checkIsWebview } from './check-is-webview'; +import { iosAppIdPattern, versionPattern } from './reg-exp-patterns'; +import { EmptyNativeParams, NativeParams, RequestHeaderType } from "./types"; + +/** + * Вытаскивает из query и headers все детали для вебвью. + * + * @returns Примечание по `appVersion`: В вебвью окружении версия всегда имеет формат `x.x.x`. + */ + +export const detectAndExtractNativeParams = ( + request: RequestHeaderType, + addCookie?: (cookieKey: string, cookieValue: string) => void +): EmptyNativeParams | NativeParams => { + const isWebview = checkIsWebview(request); + + if (!isWebview) { + return { isWebview } as EmptyNativeParams; + } + + const { + [THEME_QUERY]: themeQuery, + // При желании через диплинк на вебвью можно передать желаемый заголовок, + // который АО установит в верхней АМ панели при загрузке АО. + // По умолчанию нужна именно пустая строка. + [TITLE]: title = '', + // Говорят, этого может и не быть в урле. Формат `com.xxxxxxxxx.app`. + [WEBVIEW_IOS_APP_ID_QUERY]: iosAppIdQuery, + [WEBVIEW_IOS_APP_VERSION_QUERY]: iosAppVersionQuery, + [WEBVIEW_WITHOUT_LAYOUT_QUERY]: withoutLayoutQuery, + [WEBVIEW_NEXT_PAGE_ID_QUERY]: nextPageId, + } = request.query as Record; + + const originalWebviewParams = extractAndJoinOriginalWebviewParams( + request.query as Record, + ); + + // Пробуем вытащить схему iOS приложения из query, если есть. + let iosAppId; + + if (iosAppIdPattern.test(iosAppIdQuery)) { + const [, appIdSubsting] = iosAppIdQuery.match(iosAppIdPattern) as string[]; + + iosAppId = appIdSubsting; + } + + // Определяем версию АМ из query или заголовка. + let appVersion = '0.0.0'; + + const appVersionFromHeaders = extractAppVersion(request); + + if (typeof iosAppVersionQuery === 'string' && versionPattern.test(iosAppVersionQuery)) { + appVersion = iosAppVersionQuery; + } else if ( + typeof appVersionFromHeaders === 'string' && + versionPattern.test(appVersionFromHeaders) + ) { + const [, versionSubstring] = appVersionFromHeaders.match(versionPattern) || []; + + appVersion = versionSubstring; + } + + const nativeParams = { + appVersion, + iosAppId, + isWebview, + theme: themeQuery === 'dark' ? 'dark' : 'light', + title, + withoutLayout: withoutLayoutQuery === 'true', + originalWebviewParams, + nextPageId: nextPageId ? Number(nextPageId) : null, + } as NativeParams; + + if(addCookie) { + addCookie(NATIVE_PARAMS_COOKIE_NAME, encodeURIComponent(JSON.stringify(nativeParams))); + } + + return nativeParams; +}; diff --git a/src/server/extract-and-join-original-webview-params.ts b/src/server/extract-and-join-original-webview-params.ts new file mode 100644 index 0000000..ff4276d --- /dev/null +++ b/src/server/extract-and-join-original-webview-params.ts @@ -0,0 +1,42 @@ +// Словарь всех известных на данный момент сервисных query параметров в webview +const webviewInitParamsDictionary = [ + 'device_app_version', + 'device_os_version', + 'device_boot_time', + 'device_timezone', + 'applicationId', + 'device_app_id', + 'device_locale', + 'paySupported', + 'device_model', + 'device_uuid', + 'device_name', + 'device_id', + 'client_id', + 'theme', + 'scope', +]; + +/** + * Данная утилита извлекает из Fastify.Query все известные + * сервисные query параметры которые добавляются к url внутри + * webview при первой инициализации и собирает их в query строку. + * + * @param query - Fastify.Query в формате объекта + * @return строка query параметров в формате: "title=Title&theme=dark..." + * */ +export const extractAndJoinOriginalWebviewParams = ( + query: Record, +): string => { + const params = new URLSearchParams(); + + webviewInitParamsDictionary.forEach((key) => { + const value = query[key]; + + if (value) { + params.set(key, value); + } + }); + + return params.toString(); +}; diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..8dbe830 --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,5 @@ +export * from './detect-and-extract-native-params'; +export * from './check-is-webview'; +export * from './types'; +export * from './reg-exp-patterns'; +export * from './extract-and-join-original-webview-params'; diff --git a/src/server/reg-exp-patterns.ts b/src/server/reg-exp-patterns.ts new file mode 100644 index 0000000..7c9f9c8 --- /dev/null +++ b/src/server/reg-exp-patterns.ts @@ -0,0 +1,9 @@ +export const iosAppIdPattern = /^com\.([a-z]+)\.app$/; + +// Android приписывает после версии тип билда, например `feature`. Нам эта инфа не нужна. +export const versionPattern = /^(\d+\.\d+\.\d+)(\s.+)?$/; + +export const webviewUaIOSPattern = new RegExp( + ['WebView', '(iPhone|iPod|iPad)(?!.*Safari)'].join('|'), + 'ig', +); diff --git a/src/server/types.ts b/src/server/types.ts new file mode 100644 index 0000000..287397e --- /dev/null +++ b/src/server/types.ts @@ -0,0 +1,16 @@ +export type RequestHeaderType = Record; + +export type EmptyNativeParams = { + isWebview: false; +}; + +export type NativeParams = { + appVersion: string; + iosAppId?: string; + isWebview: true; + theme: 'dark' | 'light'; + title: string; + withoutLayout: boolean; + originalWebviewParams: string; + nextPageId: number | null; +}; diff --git a/src/server/utils.ts b/src/server/utils.ts new file mode 100644 index 0000000..ba0c91f --- /dev/null +++ b/src/server/utils.ts @@ -0,0 +1,59 @@ +import { + CAPSULE_UA_SUBSTR, + AKEY_UA_SUBSTR, + VOSKHOD_UA_SUBSTR, + NATIVE_PARAMS_COOKIE_NAME +} from './constants'; +import { RequestHeaderType } from './types'; + +/** + * Заголовок с версией приложения, который посылает вебвью из AM Android + */ +const AppVersion = 'app-version'; + +/** + * Возвращает `app-version` из заголовков запроса + */ +export function extractAppVersion(request: RequestHeaderType): string | undefined { + return request.headers[AppVersion]; +} + +/** + * Возвращает `User-agent` из заголовков запроса + */ +export function extractUserAgent(request: RequestHeaderType): string { + return request.headers['user-agent']; +} + +/** + * Возвращает объект с `webview-параметрами` из cookies + */ +export function extractNativeParamsFromCookies(request: RequestHeaderType): Record | null { + const cookieHeader = request.headers['cookie']; + + if (!cookieHeader) { + return {}; + } + + const cookiesArray = cookieHeader.split('; '); + const cookieString = cookiesArray.find((cookie: string) => cookie.startsWith(`${NATIVE_PARAMS_COOKIE_NAME}=`)); + + if (!cookieString) return null; + + const [, value] = cookieString.split('='); + + try { + return JSON.parse(decodeURIComponent(value)); + } catch { + return null; + } +} + + +/** + * Проверка по юзерагенту на сервере + */ +export const isAkeyWebview = (userAgent: string) => + userAgent.includes(CAPSULE_UA_SUBSTR) || + userAgent.includes(AKEY_UA_SUBSTR) || + userAgent.includes(VOSKHOD_UA_SUBSTR); diff --git a/test/constants.test.ts b/test/client/constants.test.ts similarity index 98% rename from test/constants.test.ts rename to test/client/constants.test.ts index 46fc24a..8310bbd 100644 --- a/test/constants.test.ts +++ b/test/client/constants.test.ts @@ -1,4 +1,4 @@ -import { nativeFeaturesFromVersion, versionToIosAppId } from '../src/constants'; +import { nativeFeaturesFromVersion, versionToIosAppId } from '../../src/client/constants'; const versionPattern = /^\d+.\d+.\d+$/; diff --git a/test/index.test.ts b/test/client/index.test.ts similarity index 98% rename from test/index.test.ts rename to test/client/index.test.ts index 8827865..c2eddca 100644 --- a/test/index.test.ts +++ b/test/client/index.test.ts @@ -1,14 +1,14 @@ /* eslint-disable @typescript-eslint/dot-notation -- отключено, чтобы можно было обращаться к приватным полям для их тестирования */ -import { BridgeToNative } from '../src'; +import { BridgeToNative } from '../../src'; import { CLOSE_WEBVIEW_SEARCH_KEY, CLOSE_WEBVIEW_SEARCH_VALUE, PREVIOUS_B2N_STATE_STORAGE_KEY, START_VERSION_ANDROID_ALLOW_OPEN_NEW_WEBVIEW, -} from '../src/constants'; +} from '../../src/client/constants'; import { mockSessionStorage } from './mock/mock-session-storage'; -import { WebViewWindow } from '../src/types'; +import { WebViewWindow } from '../../src/client/types'; const mockedNativeFallbacksInstance = {}; const mockedNativeNavigationAndTitleInstance = { @@ -18,14 +18,14 @@ const MockedNativeNavigationAndTitleConstructor = jest.fn( () => mockedNativeNavigationAndTitleInstance, ); -jest.mock('../src/native-fallbacks', () => ({ +jest.mock('../../src/client/native-fallbacks', () => ({ __esModule: true, NativeFallbacks: function MockedNativeFallbacksConstructor() { return mockedNativeFallbacksInstance; }, })); -jest.mock('../src/native-navigation-and-title', () => ({ +jest.mock('../../src/client/native-navigation-and-title', () => ({ __esModule: true, get NativeNavigationAndTitle() { return MockedNativeNavigationAndTitleConstructor; diff --git a/test/integration.test.ts b/test/client/integration.test.ts similarity index 99% rename from test/integration.test.ts rename to test/client/integration.test.ts index 7546823..67cf164 100644 --- a/test/integration.test.ts +++ b/test/client/integration.test.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/dot-notation -- отключено, чтобы можно было обращаться к приватным полям для их тестирования */ -import { BridgeToNative } from '../src/bridge-to-native'; +import { BridgeToNative } from '../../src/client/bridge-to-native'; describe('BridgeToNative integration testing', () => { const defaultAmParams = { diff --git a/test/mock/mock-session-storage.ts b/test/client/mock/mock-session-storage.ts similarity index 100% rename from test/mock/mock-session-storage.ts rename to test/client/mock/mock-session-storage.ts diff --git a/test/native-fallbacks.test.ts b/test/client/native-fallbacks.test.ts similarity index 97% rename from test/native-fallbacks.test.ts rename to test/client/native-fallbacks.test.ts index 3894258..a5adc12 100644 --- a/test/native-fallbacks.test.ts +++ b/test/client/native-fallbacks.test.ts @@ -1,7 +1,7 @@ -import type { BridgeToNative } from '../src/bridge-to-native'; -import { nativeFeaturesFromVersion } from '../src/constants'; -import { NativeFallbacks } from '../src/native-fallbacks'; -import { PdfType } from '../src/types'; +import type { BridgeToNative } from '../../src/client/bridge-to-native'; +import { nativeFeaturesFromVersion } from '../../src/client/constants'; +import { NativeFallbacks } from '../../src/client/native-fallbacks'; +import { PdfType } from '../../src/client/types'; let androidEnvFlag = false; let iosAppId: string | undefined; @@ -32,7 +32,7 @@ const mockedBridgeToAmInstance = { }, } as unknown as BridgeToNative; -jest.mock('../src/bridge-to-native', () => ({ +jest.mock('../../src/client/bridge-to-native', () => ({ __esModule: true, BridgeToNative: function MockedBridgeToAmConstructor() { return mockedBridgeToAmInstance; diff --git a/test/native-navigation-and-title.test.ts b/test/client/native-navigation-and-title.test.ts similarity index 99% rename from test/native-navigation-and-title.test.ts rename to test/client/native-navigation-and-title.test.ts index 8277703..61da169 100644 --- a/test/native-navigation-and-title.test.ts +++ b/test/client/native-navigation-and-title.test.ts @@ -1,10 +1,10 @@ /* eslint-disable @typescript-eslint/dot-notation -- отключено, чтобы можно было обращаться к приватным полям для их тестирования */ -import type { BridgeToNative } from '../src'; +import type { BridgeToNative } from '../../src'; -import { PREVIOUS_NATIVE_NAVIGATION_AND_TITLE_STATE_STORAGE_KEY } from '../src/constants'; +import { PREVIOUS_NATIVE_NAVIGATION_AND_TITLE_STATE_STORAGE_KEY } from '../../src/client/constants'; import { mockSessionStorage } from './mock/mock-session-storage'; -import { NativeNavigationAndTitle } from '../src/native-navigation-and-title'; +import { NativeNavigationAndTitle } from '../../src/client/native-navigation-and-title'; let androidEnvFlag = false; let mockedSetPageSettings: unknown; @@ -37,7 +37,7 @@ Object.defineProperty(global, 'handleRedirect', { configurable: true, }); -jest.mock('../src/bridge-to-native', () => ({ +jest.mock('../../src/client/bridge-to-native', () => ({ __esModule: true, BridgeToNative: function MockedBridgeToAmConstructor() { return mockedBridgeToNativeInstance; diff --git a/test/utils.test.ts b/test/client/utils.test.ts similarity index 96% rename from test/utils.test.ts rename to test/client/utils.test.ts index 25c56c7..115c278 100644 --- a/test/utils.test.ts +++ b/test/client/utils.test.ts @@ -3,8 +3,8 @@ import { getAppId, getUrlInstance, isValidVersionFormat, -} from '../src/utils'; -import { ANDROID_APP_ID } from '../src/constants'; +} from '../../src/client/utils'; +import { ANDROID_APP_ID } from '../../src/client/constants'; describe('extractAppNameRouteAndQuery', () => { it('should extract app-name without path and query', () => { diff --git a/test/server/check-is-webview.test.ts b/test/server/check-is-webview.test.ts new file mode 100644 index 0000000..0a36d1d --- /dev/null +++ b/test/server/check-is-webview.test.ts @@ -0,0 +1,124 @@ +import { checkIsWebview, isWebviewByUserAgent, isWebviewByCookies } from '../../src/server/check-is-webview'; + +const UA_IPHONE = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1'; +const UA_AMWEBVIEW = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148'; +const UA_CAPSULE = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Capsule pctest-fejfksefsef-sfsevsdfsefs-fsefses iOS 1.0'; +const UA_AKEY = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 AKEY pctest-fejfksefsef-sfsevsdfsefs-fsefses iOS 1.0'; + +const UA_VOSKHOD = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 VOSKHOD pctest-fejfksefsef-sfsevsdfsefs-fsefses iOS 1.0'; + +describe('isAlfaMobileWebview', () => { + it('should not detect webview environment in Capsule', () => { + expect( + checkIsWebview({ + headers: { + 'user-agent': UA_CAPSULE, + }, + query: {}, + }), + ).toBeFalsy(); + }); + + it('should not detect webview environment in Akey 2.0', () => { + expect( + checkIsWebview({ + headers: { + 'user-agent': UA_AKEY, + }, + query: {}, + }), + ).toBeFalsy(); + }); + + it('should not detect webview environment in VOSKHOD', () => { + expect( + checkIsWebview({ + headers: { + 'user-agent': UA_VOSKHOD, + }, + query: {}, + }), + ).toBeFalsy(); + }); + + it('should not detect webview environment in Capsule even while `app-version` header is present', () => { + expect( + checkIsWebview({ + headers: { + 'app-version': '10.0.0', + 'user-agent': UA_CAPSULE, + }, + query: {}, + }), + ).toBeFalsy(); + }); + + it('should not detect AM-webview while UA is not matched to webview-UA', () => { + expect( + checkIsWebview({ + headers: { + 'user-agent': UA_IPHONE, + }, + query: {}, + }), + ).toBeFalsy(); + }); + + it('should detect AM-webview while UA is matched to webview-UA', () => { + expect( + checkIsWebview({ + headers: { + 'user-agent': UA_AMWEBVIEW, + }, + query: {}, + }), + ).toBe(true); + }); + + it('should detect AM-webview while `app-version` header is present', () => { + expect( + checkIsWebview({ + headers: { + 'app-version': '10.0.0', + 'user-agent': UA_IPHONE, + }, + query: {}, + }), + ).toBe(true); + }); +}); + +describe('isAlfaMobileWebviewByUserAgent', () => { + it('should return false if AM-webview environment in Capsule', () => { + expect(isWebviewByUserAgent(UA_CAPSULE, undefined)).toBeFalsy(); + }); + + it('should return false if AM-webview environment in Akey 2.0', () => { + expect(isWebviewByUserAgent(UA_AKEY, undefined)).toBeFalsy(); + }); + + it('should return false if AM-webview environment in VOSKHOD', () => { + expect(isWebviewByUserAgent(UA_VOSKHOD, undefined)).toBeFalsy(); + }); + + it('should return false if AM-webview environment in Capsule even while `app-version` exist', () => { + expect(isWebviewByUserAgent(UA_CAPSULE, '10.0.0')).toBeFalsy(); + }); + + it('should return false if AM-webview while UA is not matched to webview-UA', () => { + expect(isWebviewByUserAgent(UA_IPHONE, undefined)).toBeFalsy(); + }); + + it('should return true if AM-webview while UA is matched to webview-UA', () => { + expect(isWebviewByUserAgent(UA_AMWEBVIEW, undefined)).toBe(true); + }); + + it('should return true if AM-webview while `app-version` header is present', () => { + expect(isWebviewByUserAgent(UA_IPHONE, '10.0.0')).toBe(true); + }); +}); diff --git a/test/server/detect-and-extract-native-params.test.ts b/test/server/detect-and-extract-native-params.test.ts new file mode 100644 index 0000000..f43e306 --- /dev/null +++ b/test/server/detect-and-extract-native-params.test.ts @@ -0,0 +1,423 @@ +import { detectAndExtractNativeParams } from '../../src/server/detect-and-extract-native-params'; +import {checkIsWebview} from "../../src/server"; + +const UA_IPHONE = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1'; +const UA_AMWEBVIEW = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148'; + +let mockedUa = UA_AMWEBVIEW; + + +const originalUtils = jest.requireActual('../../src/server/utils'); +let mockedExtractAppVersion = originalUtils.extractAppVersion; + +jest.mock('../../src/server/utils', () => ({ + extractUserAgent: jest.fn(() => mockedUa), + extractAppVersion: jest.fn((...args) => mockedExtractAppVersion(...args)), +})); + +jest.mock('../../src/server/check-is-webview'); + +describe('detectAndExtractNativeParams', () => { + const mockCheckIsWebview = checkIsWebview as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockedUa = UA_AMWEBVIEW; + mockedExtractAppVersion = originalUtils.extractAppVersion; + mockCheckIsWebview.mockReturnValue(true); + }); + + describe('non-webview', () => { + beforeEach(() => { + mockedUa = UA_IPHONE; + mockCheckIsWebview.mockReturnValue(false); + }); + + it.each([ + ['appVersion from query', { headers: {}, query: { device_app_version: '10.10.10' } }], + [ + 'iosAppId from query', + { + headers: {}, + query: { applicationId: 'com.aconcierge.app' }, + }, + ], + ['dark theme from query', { headers: {}, query: { theme: 'dark' } }], + ['light theme from query', { headers: {}, query: { theme: 'light' } }], + ['theme from query', { headers: {}, query: { title: 'Title' } }], + ['withoutLayout from query', { headers: {}, query: { without_layout: 'true' } }], + ])('should not pass `%s` in non-webview environment', (_, request) => { + expect(detectAndExtractNativeParams(request)).toEqual({ + isWebview: false, + }); + }); + }); + + describe('appVersion', () => { + const restReturnValues = { + isWebview: true, + theme: 'light', + title: '', + withoutLayout: false, + nextPageId: null, + originalWebviewParams: '', + }; + + it('should pass default appVersion', () => { + const request = { headers: {}, query: { is_webview: 'true' } }; + + expect(detectAndExtractNativeParams(request)).toEqual({ + appVersion: '0.0.0', + ...restReturnValues, + }); + }); + + it.each(['10.11.12', '10.1.2', '10.500.25'])( + 'should pass appVersion `%s` from headers', + (version) => { + const request = { + headers: { 'app-version': version }, + query: { is_webview: 'true' }, + }; + + expect(detectAndExtractNativeParams(request)).toEqual({ + appVersion: version, + ...restReturnValues, + }); + }, + ); + + it.each(['10.11', '10.11.12.13', 'some-version'])( + 'should not pass wrong format `%s` of appVersion from headers', + (version) => { + const request = { + headers: { 'app-version': version }, + query: { is_webview: 'true' }, + }; + + expect(detectAndExtractNativeParams(request)).toEqual({ + appVersion: '0.0.0', + ...restReturnValues, + }); + }, + ); + + it.each(['10.11.12', '10.1.2', '10.500.25'])( + 'should pass appVersion `%s` from query', + (version) => { + const request = { + headers: {}, + query: { device_app_version: version, is_webview: 'true' }, + }; + + expect(detectAndExtractNativeParams(request)).toEqual({ + appVersion: version, + ...restReturnValues, + originalWebviewParams: `device_app_version=${version}`, + }); + }, + ); + + it.each(['10.11', '10.11.12.13', 'some-version'])( + 'should not pass wrong format `%s` of appVersion from query', + (version) => { + const request = { + headers: {}, + query: { device_app_version: version, is_webview: 'true' }, + }; + + expect(detectAndExtractNativeParams(request)).toEqual({ + appVersion: '0.0.0', + ...restReturnValues, + originalWebviewParams: `device_app_version=${version}`, + }); + }, + ); + + it('should pass appVersion from query while appVersion exists both in query and in headers', () => { + const request = { + headers: { 'app-version': '10.11.12' }, + query: { device_app_version: '13.14.15', is_webview: 'true' }, + }; + + expect(detectAndExtractNativeParams(request)).toEqual({ + appVersion: '13.14.15', + ...restReturnValues, + originalWebviewParams: 'device_app_version=13.14.15', + }); + }); + + it('should pass only version from full version-string on Android', () => { + const request = { + headers: { 'app-version': '10.11.12 feature' }, + query: { is_webview: 'true' }, + }; + + expect(detectAndExtractNativeParams(request)).toEqual({ + appVersion: '10.11.12', + ...restReturnValues, + }); + }); + }); + + describe('iosAppId', () => { + const restReturnValues = { + appVersion: '0.0.0', + isWebview: true, + theme: 'light', + title: '', + withoutLayout: false, + nextPageId: null, + originalWebviewParams: '', + }; + + it('should pass trimmed ios `applicationId`', () => { + const request = { + headers: {}, + query: { applicationId: 'com.aconcierge.app', is_webview: 'true' }, + }; + + expect(detectAndExtractNativeParams(request)).toEqual({ + ...restReturnValues, + iosAppId: 'aconcierge', + originalWebviewParams: `applicationId=${request.query.applicationId}`, + }); + }); + + it('should ignore unknown value of `applicationId`', () => { + const request = { + headers: {}, + query: { applicationId: 'something-strange', is_webview: 'true' }, + }; + + expect(detectAndExtractNativeParams(request)).toEqual({ + ...restReturnValues, + originalWebviewParams: `applicationId=${request.query.applicationId}${restReturnValues.originalWebviewParams}`, + }); + }); + }); + + describe('isWebview', () => { + const restReturnValues = { + appVersion: '0.0.0', + theme: 'light', + title: '', + withoutLayout: false, + nextPageId: null, + originalWebviewParams: '', + }; + + it('should pass non AM-webview env', () => { + mockCheckIsWebview.mockReturnValue(false); + // mockedIsAlfaMobileWebview = jest.fn(() => false); + + expect(detectAndExtractNativeParams({ headers: {}, query: {} })).toEqual({ + isWebview: false, + }); + }); + + it('should pass AM-webview env', () => { + mockCheckIsWebview.mockReturnValue(true); + // mockedIsAlfaMobileWebview = jest.fn(() => true); + + expect(detectAndExtractNativeParams({ headers: {}, query: {} })).toEqual({ + ...restReturnValues, + isWebview: true, + }); + }); + }); + + describe('theme', () => { + const restReturnValues = { + appVersion: '0.0.0', + isWebview: true, + title: '', + withoutLayout: false, + nextPageId: null, + originalWebviewParams: '', + }; + + it.each(['dark', 'light'])('should pass theme=%s', (theme) => { + const request = { headers: {}, query: { is_webview: 'true', theme } }; + + expect(detectAndExtractNativeParams(request)).toEqual({ + ...restReturnValues, + theme, + originalWebviewParams: `theme=${theme}`, + }); + }); + + it('should pass light theme while theme in query is unknown', () => { + const request = { headers: {}, query: { is_webview: 'true', theme: 'diamond' } }; + + expect(detectAndExtractNativeParams(request)).toEqual({ + ...restReturnValues, + theme: 'light', + originalWebviewParams: 'theme=diamond', + }); + }); + }); + + describe('title', () => { + const restReturnValues = { + appVersion: '0.0.0', + isWebview: true, + theme: 'light', + withoutLayout: false, + nextPageId: null, + originalWebviewParams: '', + }; + + it('should default title', () => { + const request = { headers: {}, query: { is_webview: 'true' } }; + + expect(detectAndExtractNativeParams(request)).toEqual({ + ...restReturnValues, + title: '', + }); + }); + + it('should pass title', () => { + const request = { headers: {}, query: { is_webview: 'true', title: 'Title' } }; + + expect(detectAndExtractNativeParams(request)).toEqual({ + ...restReturnValues, + title: 'Title', + originalWebviewParams: '', + }); + }); + }); + + describe('originalWebviewParams', () => { + const returnValues = { + appVersion: '12.26.0', + isWebview: true, + theme: 'light', + title: 'Title', + withoutLayout: false, + nextPageId: null, + iosAppId: 'aconcierge', + originalWebviewParams: + 'device_app_version=12.26.0&device_os_version=iOS+16.1&device_boot_time=38933&device_timezone=%2B0300&applicationId=com.aconcierge.app&device_app_id=8441576F&device_locale=ru-US&device_model=x86_64&device_uuid=2E32AFD5&device_name=iPhone+14&device_id=1842D0AA&client_id=mobile-app&theme=light&scope=openid+mobile-bank', + }; + const request = { + headers: {}, + query: { + device_app_id: '8441576F', + device_uuid: '2E32AFD5', + device_id: '1842D0AA', + applicationId: 'com.aconcierge.app', + device_os_version: 'iOS 16.1', + device_app_version: '12.26.0', + scope: 'openid mobile-bank', + device_boot_time: '38933', + device_name: 'iPhone 14', + device_timezone: '+0300', + client_id: 'mobile-app', + device_locale: 'ru-US', + device_model: 'x86_64', + is_webview: true, + title: 'Title', + theme: 'light', + }, + }; + + it('should add correct originalWebviewParams', () => { + expect(detectAndExtractNativeParams(request)).toEqual(returnValues); + }); + }); + + describe('withoutLayout', () => { + const restReturnValues = { + appVersion: '0.0.0', + isWebview: true, + theme: 'light', + title: '', + nextPageId: null, + originalWebviewParams: '', + }; + + it('should pass withoutLayout=true', () => { + const request = { headers: {}, query: { is_webview: 'true', without_layout: 'true' } }; + + expect(detectAndExtractNativeParams(request)).toEqual({ + ...restReturnValues, + withoutLayout: true, + }); + }); + + it.each(['false', 'kekus', '1'])('should not pass withoutLayout=%s', (queryValue) => { + const request = { + headers: {}, + query: { is_webview: 'true', without_layout: queryValue }, + }; + + expect(detectAndExtractNativeParams(request)).toEqual({ + ...restReturnValues, + withoutLayout: false, + }); + }); + }); + + describe('nextPageId', () => { + const request = { + headers: {}, + query: { + nextPageId: 4, + is_webview: true, + }, + }; + + const returnValues = { + appVersion: '0.0.0', + iosAppId: undefined, + isWebview: true, + nextPageId: 4, + originalWebviewParams: '', + theme: 'light', + title: '', + withoutLayout: false, + }; + + it('should return correct nextPageId', () => { + expect(detectAndExtractNativeParams(request)).toEqual(returnValues); + }); + }); + + describe('addCookie', () => { + const addCookieMock = jest.fn(); + const NATIVE_PARAMS_COOKIE_NAME = 'app_native_params'; + + const request = { + headers: {}, + query: { + nextPageId: 4, + is_webview: true, + }, + }; + + const returnValues = { + appVersion: '0.0.0', + iosAppId: undefined, + isWebview: true, + theme: 'light', + title: '', + withoutLayout: false, + originalWebviewParams: '', + nextPageId: 4, + }; + + it('should use addCookie function to set cookie with nativeParams', () => { + detectAndExtractNativeParams(request, addCookieMock); + + const encodedNativeParams = encodeURIComponent(JSON.stringify(returnValues)); + + expect(addCookieMock).toHaveBeenCalledWith( + NATIVE_PARAMS_COOKIE_NAME, + encodedNativeParams + ); + }); + }); +}); diff --git a/test/server/extract-and-join-original-webview-params.test.ts b/test/server/extract-and-join-original-webview-params.test.ts new file mode 100644 index 0000000..e026307 --- /dev/null +++ b/test/server/extract-and-join-original-webview-params.test.ts @@ -0,0 +1,45 @@ +import { extractAndJoinOriginalWebviewParams } from '../../src/server/extract-and-join-original-webview-params'; + +const fastifyRequestQueryExample = { + first: 'asdasdsddsfdsfsdfdsas-8441576F-A09F-8441576F-A09F', + device_app_id: '8441576F-A09F-41E9-89A7-EE1FA486C20A', + device_uuid: '2E32AFD5-F50B-4B2F-B758-CAE59DF2BF6C', + device_id: '1842D0AA-0008-4941-93E0-4FD80E087841', + applicationId: 'com.aconcierge.app', + device_os_version: 'iOS 16.1', + device_app_version: '12.26.0', + scope: 'openid mobile-bank', + device_boot_time: '38933', + device_name: 'iPhone 14', + device_timezone: '+0300', + test3: 'afsdsdfsdf,dsfd', + client_id: 'mobile-app', + device_locale: 'ru-US', + device_model: 'x86_64', + paySupported: 'true', + test4: 'etrrtr', + title: 'Title', + theme: 'light', + final: 'dddd', +}; + +describe('extractAndJoinOriginalAlfaMobileWebviewParams', () => { + it('should return empty string if no original webview query', () => { + const otherParams = { + first: 'sdas', + test: 'qwe', + test2: 'fggg', + test3: 'afsd', + test4: 'etrrtr', + final: 'dddd', + }; + + expect(extractAndJoinOriginalWebviewParams(otherParams)).toBe(''); + }); + + it('should return all original webview query', () => { + expect(extractAndJoinOriginalWebviewParams(fastifyRequestQueryExample)).toBe( + 'device_app_version=12.26.0&device_os_version=iOS+16.1&device_boot_time=38933&device_timezone=%2B0300&applicationId=com.aconcierge.app&device_app_id=8441576F-A09F-41E9-89A7-EE1FA486C20A&device_locale=ru-US&paySupported=true&device_model=x86_64&device_uuid=2E32AFD5-F50B-4B2F-B758-CAE59DF2BF6C&device_name=iPhone+14&device_id=1842D0AA-0008-4941-93E0-4FD80E087841&client_id=mobile-app&theme=light&scope=openid+mobile-bank', + ); + }); +}); diff --git a/test/server/utils.test.ts b/test/server/utils.test.ts new file mode 100644 index 0000000..1fd585a --- /dev/null +++ b/test/server/utils.test.ts @@ -0,0 +1,46 @@ +import { extractNativeParamsFromCookies } from '../../src/server/utils'; +import { NATIVE_PARAMS_COOKIE_NAME } from '../../src/server/constants'; + +describe('extractNativeParamsFromCookies', () => { + it('should return an empty object if cookie header is missing', () => { + const request = { headers: {} }; + const result = extractNativeParamsFromCookies(request); + expect(result).toEqual({}); + }); + + it('should return an empty object if native params cookie is not present', () => { + const request = { headers: { cookie: 'otherCookie=value' } }; + const result = extractNativeParamsFromCookies(request); + expect(result).toEqual(null); + }); + + it('should return parsed object if native params cookie is present', () => { + const nativeParams = { param1: 'value1', param2: 'value2' }; + const encodedNativeParams = encodeURIComponent(JSON.stringify(nativeParams)); + const request = { headers: { cookie: `${NATIVE_PARAMS_COOKIE_NAME}=${encodedNativeParams}` } }; + + const result = extractNativeParamsFromCookies(request); + expect(result).toEqual(nativeParams); + }); + + it('should return null if native params cookie has invalid JSON', () => { + const invalidJsonValue = 'invalid%7Bjson'; + const request = { headers: { cookie: `${NATIVE_PARAMS_COOKIE_NAME}=${invalidJsonValue}` } }; + + const result = extractNativeParamsFromCookies(request); + expect(result).toBeNull(); + }); + + it('should handle multiple cookies and return only native params cookie value', () => { + const nativeParams = { param1: 'value1', param2: 'value2' }; + const encodedNativeParams = encodeURIComponent(JSON.stringify(nativeParams)); + const request = { + headers: { + cookie: `otherCookie=value; ${NATIVE_PARAMS_COOKIE_NAME}=${encodedNativeParams}; anotherCookie=anotherValue` + } + }; + + const result = extractNativeParamsFromCookies(request); + expect(result).toEqual(nativeParams); + }); +});