diff --git a/src/client/index.ts b/src/client/index.ts new file mode 100644 index 0000000..f5493d0 --- /dev/null +++ b/src/client/index.ts @@ -0,0 +1,3 @@ +export { BridgeToNative } from './bridge-to-native'; +export { NativeParams, Theme, Environment, NativeFeatureKey, PdfType } from './types'; +export { getNativeParamsFromCookies } from './utils'; diff --git a/src/client/types.ts b/src/client/types.ts index 46881ce..9667bed 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -1,13 +1,6 @@ -export type NativeParams = { - appVersion: string; - title?: string; - // В ранних версиях iOS приложение не пробрасывет схему приложения в URL в прод окружении. - // Для таких версий есть мэппинг `./constants` → `versionToIosAppId`. - iosAppId?: string; - theme: string; - nextPageId: number | null; - originalWebviewParams: string; -}; +import { NativeParamsType } from "../shared/types"; + +export type NativeParams = NativeParamsType; export type NativeFeatureKey = // Возможность работы с геолокацией. diff --git a/src/client/utils.ts b/src/client/utils.ts index 6da2c19..9225456 100644 --- a/src/client/utils.ts +++ b/src/client/utils.ts @@ -1,5 +1,6 @@ import { Environment } from './types'; import { ANDROID_APP_ID } from './constants'; +import { extractNativeParamsFromCookies } from "../shared/utils"; /** * Разделяет веб ссылку на компоненты @@ -76,3 +77,8 @@ export const getAppId = (environment: Environment, iosAppId?: string) => { return null; }; + +/** + * Возвращает объект с `webview-параметрами` из cookies + */ +export const getNativeParamsFromCookies = (): Record | null => extractNativeParamsFromCookies(document.cookie); diff --git a/src/index.ts b/src/index.ts index 932b645..bf347c6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,2 @@ -export { BridgeToNative } from './client/bridge-to-native'; -export { NativeParams, Theme, Environment, NativeFeatureKey, PdfType } from './client/types'; +export * from './client' +export * from './server'; diff --git a/src/server/constants.ts b/src/server/constants.ts index 9827233..8010b72 100644 --- a/src/server/constants.ts +++ b/src/server/constants.ts @@ -1,13 +1,7 @@ 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/extract-and-join-original-webview-params.ts b/src/server/extract-and-join-original-webview-params.ts index ff4276d..6421dd5 100644 --- a/src/server/extract-and-join-original-webview-params.ts +++ b/src/server/extract-and-join-original-webview-params.ts @@ -18,11 +18,11 @@ const webviewInitParamsDictionary = [ ]; /** - * Данная утилита извлекает из Fastify.Query все известные + * Данная утилита извлекает из запроса все известные * сервисные query параметры которые добавляются к url внутри * webview при первой инициализации и собирает их в query строку. * - * @param query - Fastify.Query в формате объекта + * @param query - Query в формате объекта * @return строка query параметров в формате: "title=Title&theme=dark..." * */ export const extractAndJoinOriginalWebviewParams = ( diff --git a/src/server/detect-and-extract-native-params.ts b/src/server/extract-native-params.ts similarity index 78% rename from src/server/detect-and-extract-native-params.ts rename to src/server/extract-native-params.ts index d1344f8..50d26db 100644 --- a/src/server/detect-and-extract-native-params.ts +++ b/src/server/extract-native-params.ts @@ -5,15 +5,14 @@ import { 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"; +import {isWebviewEnvironment} from "./is-webview-environment"; /** * Вытаскивает из query и headers все детали для вебвью. @@ -21,20 +20,17 @@ import { EmptyNativeParams, NativeParams, RequestHeaderType } from "./types"; * @returns Примечание по `appVersion`: В вебвью окружении версия всегда имеет формат `x.x.x`. */ -export const detectAndExtractNativeParams = ( - request: RequestHeaderType, - addCookie?: (cookieKey: string, cookieValue: string) => void -): EmptyNativeParams | NativeParams => { - const isWebview = checkIsWebview(request); +export const extractNativeParams = ( + request: RequestHeaderType +): NativeParams | null => { - if (!isWebview) { - return { isWebview } as EmptyNativeParams; + if(!isWebviewEnvironment(request)) { + return null; } const { [THEME_QUERY]: themeQuery, - // При желании через диплинк на вебвью можно передать желаемый заголовок, - // который АО установит в верхней АМ панели при загрузке АО. + // При желании через диплинк на вебвью можно передать желаемый заголовок // По умолчанию нужна именно пустая строка. [TITLE]: title = '', // Говорят, этого может и не быть в урле. Формат `com.xxxxxxxxx.app`. @@ -57,7 +53,7 @@ export const detectAndExtractNativeParams = ( iosAppId = appIdSubsting; } - // Определяем версию АМ из query или заголовка. + // Определяем версию приложения из query или заголовка. let appVersion = '0.0.0'; const appVersionFromHeaders = extractAppVersion(request); @@ -76,7 +72,7 @@ export const detectAndExtractNativeParams = ( const nativeParams = { appVersion, iosAppId, - isWebview, + isWebview: true, theme: themeQuery === 'dark' ? 'dark' : 'light', title, withoutLayout: withoutLayoutQuery === 'true', @@ -84,9 +80,5 @@ export const detectAndExtractNativeParams = ( 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/index.ts b/src/server/index.ts index 8dbe830..f948ef9 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,5 +1,4 @@ -export * from './detect-and-extract-native-params'; -export * from './check-is-webview'; +export * from './is-webview-environment'; +export * from './extract-native-params'; +export * from './set-native-params-cookie'; export * from './types'; -export * from './reg-exp-patterns'; -export * from './extract-and-join-original-webview-params'; diff --git a/src/server/check-is-webview.ts b/src/server/is-webview-environment.ts similarity index 72% rename from src/server/check-is-webview.ts rename to src/server/is-webview-environment.ts index 6bbc8ae..2042b70 100644 --- a/src/server/check-is-webview.ts +++ b/src/server/is-webview-environment.ts @@ -1,8 +1,7 @@ import { extractAppVersion, extractUserAgent, - isAkeyWebview, - extractNativeParamsFromCookies + extractNativeParamsFromCookieHeader } from './utils'; import { RequestHeaderType } from './types'; import { versionPattern, webviewUaIOSPattern } from './reg-exp-patterns'; @@ -11,10 +10,6 @@ export const isWebviewByUserAgent = ( userAgent: string, appVersion: string | undefined, ) => { - if (userAgent && isAkeyWebview(userAgent)) { - return false; - } - return ( (appVersion && versionPattern.test(appVersion)) || !!userAgent?.match(webviewUaIOSPattern) ); @@ -23,14 +18,14 @@ export const isWebviewByUserAgent = ( export const isWebviewByCookies = (nativeParamsFromCookies: Record | null) => { return !!(nativeParamsFromCookies && nativeParamsFromCookies.isWebview) } -export const checkIsWebview = ( +export const isWebviewEnvironment = ( request: RequestHeaderType, ): boolean => { const userAgent = extractUserAgent(request); // `app-version` в заголовках – индикатор вебвью. В iOS есть только в первом запросе от webview const appVersion = extractAppVersion(request); - const nativeParamsFromCookies = extractNativeParamsFromCookies(request); + const nativeParams = extractNativeParamsFromCookieHeader(request); - return isWebviewByCookies(nativeParamsFromCookies) || isWebviewByUserAgent(userAgent, appVersion); + return isWebviewByCookies(nativeParams) || isWebviewByUserAgent(userAgent, appVersion); }; diff --git a/src/server/set-native-params-cookie.ts b/src/server/set-native-params-cookie.ts new file mode 100644 index 0000000..5e4ca61 --- /dev/null +++ b/src/server/set-native-params-cookie.ts @@ -0,0 +1,6 @@ +import {NATIVE_PARAMS_COOKIE_NAME} from "./constants"; + +export const setNativeParamsCookie = (params: Record, setCookie: (name: string, value: string) => void): void => { + setCookie(NATIVE_PARAMS_COOKIE_NAME, encodeURIComponent(JSON.stringify(params))) +} + diff --git a/src/server/types.ts b/src/server/types.ts index 287397e..a4434a7 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -1,16 +1,11 @@ +import type { NativeParamsType } from '../shared/types'; export type RequestHeaderType = Record; export type EmptyNativeParams = { isWebview: false; }; -export type NativeParams = { - appVersion: string; - iosAppId?: string; +export type NativeParams = NativeParamsType & { 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 index ba0c91f..ada02a5 100644 --- a/src/server/utils.ts +++ b/src/server/utils.ts @@ -1,10 +1,5 @@ -import { - CAPSULE_UA_SUBSTR, - AKEY_UA_SUBSTR, - VOSKHOD_UA_SUBSTR, - NATIVE_PARAMS_COOKIE_NAME -} from './constants'; import { RequestHeaderType } from './types'; +import { extractNativeParamsFromCookies } from "../shared/utils"; /** * Заголовок с версией приложения, который посылает вебвью из AM Android @@ -28,32 +23,6 @@ export function extractUserAgent(request: RequestHeaderType): string { /** * Возвращает объект с `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 function extractNativeParamsFromCookieHeader(request: RequestHeaderType): Record | null { + return extractNativeParamsFromCookies(request.headers['cookie']) } - - -/** - * Проверка по юзерагенту на сервере - */ -export const isAkeyWebview = (userAgent: string) => - userAgent.includes(CAPSULE_UA_SUBSTR) || - userAgent.includes(AKEY_UA_SUBSTR) || - userAgent.includes(VOSKHOD_UA_SUBSTR); diff --git a/src/shared/types.ts b/src/shared/types.ts new file mode 100644 index 0000000..94b1f37 --- /dev/null +++ b/src/shared/types.ts @@ -0,0 +1,10 @@ +export type NativeParamsType = { + appVersion: string; + title?: string; + // В ранних версиях iOS приложение не пробрасывет схему приложения в URL в прод окружении. + // Для таких версий есть мэппинг `./constants` → `versionToIosAppId`. + iosAppId?: string; + theme: string; + nextPageId: number | null; + originalWebviewParams: string; +}; diff --git a/src/shared/utils.ts b/src/shared/utils.ts new file mode 100644 index 0000000..441e1c5 --- /dev/null +++ b/src/shared/utils.ts @@ -0,0 +1,24 @@ +import { NATIVE_PARAMS_COOKIE_NAME } from "../server/constants"; + +/** + * Возвращает объект с `webview-параметрами` из cookies + */ +export function extractNativeParamsFromCookies(cookies?: string): Record | null { + + if (!cookies) { + return null; + } + + const cookiesArray = cookies.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; + } +} diff --git a/test/server/check-is-webview.test.ts b/test/server/check-is-webview.test.ts deleted file mode 100644 index 0a36d1d..0000000 --- a/test/server/check-is-webview.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -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 deleted file mode 100644 index f43e306..0000000 --- a/test/server/detect-and-extract-native-params.test.ts +++ /dev/null @@ -1,423 +0,0 @@ -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 index e026307..18c46f2 100644 --- a/test/server/extract-and-join-original-webview-params.test.ts +++ b/test/server/extract-and-join-original-webview-params.test.ts @@ -23,7 +23,7 @@ const fastifyRequestQueryExample = { final: 'dddd', }; -describe('extractAndJoinOriginalAlfaMobileWebviewParams', () => { +describe('extractAndJoinOriginalWebviewParams', () => { it('should return empty string if no original webview query', () => { const otherParams = { first: 'sdas', diff --git a/test/server/extract-native-params.ts b/test/server/extract-native-params.ts new file mode 100644 index 0000000..0000111 --- /dev/null +++ b/test/server/extract-native-params.ts @@ -0,0 +1,382 @@ +import { extractNativeParams } from '../../src/server/extract-native-params'; +import {isWebviewEnvironment} 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_WEBVIEW = + '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_WEBVIEW; + + +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/is-webview-environment'); + +describe('extractNativeParams', () => { + const mockIsWebviewEnv = isWebviewEnvironment as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockedUa = UA_WEBVIEW; + mockedExtractAppVersion = originalUtils.extractAppVersion; + mockIsWebviewEnv.mockReturnValue(true); + }); + + describe('non-webview', () => { + beforeEach(() => { + mockedUa = UA_IPHONE; + mockIsWebviewEnv.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(extractNativeParams(request)).toEqual(null); + }); + }); + + 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(extractNativeParams(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(extractNativeParams(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(extractNativeParams(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(extractNativeParams(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(extractNativeParams(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(extractNativeParams(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(extractNativeParams(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(extractNativeParams(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(extractNativeParams(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 webview env', () => { + mockIsWebviewEnv.mockReturnValue(false); + + expect(extractNativeParams({ headers: {}, query: {} })).toEqual(null); + }); + + it('should pass webview env', () => { + mockIsWebviewEnv.mockReturnValue(true); + + expect(extractNativeParams({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(extractNativeParams(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(extractNativeParams(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(extractNativeParams(request)).toEqual({ + ...restReturnValues, + title: '', + }); + }); + + it('should pass title', () => { + const request = {headers: {}, query: {is_webview: 'true', title: 'Title'}}; + + expect(extractNativeParams(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(extractNativeParams(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(extractNativeParams(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(extractNativeParams(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(extractNativeParams(request)).toEqual(returnValues); + }); + }); +}); diff --git a/test/server/is-webview-environment.test.ts b/test/server/is-webview-environment.test.ts new file mode 100644 index 0000000..5a9b38e --- /dev/null +++ b/test/server/is-webview-environment.test.ts @@ -0,0 +1,56 @@ +import { isWebviewEnvironment, isWebviewByUserAgent } from '../../src/server/is-webview-environment'; + +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_WEBVIEW = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148'; + +describe('isWebviewEnvironment', () => { + it('should not detect webview while UA is not matched to webview-UA', () => { + expect( + isWebviewEnvironment({ + headers: { + 'user-agent': UA_IPHONE, + }, + query: {}, + }), + ).toBeFalsy(); + }); + + it('should detect webview while UA is matched to webview-UA', () => { + expect( + isWebviewEnvironment({ + headers: { + 'user-agent': UA_WEBVIEW, + }, + query: {}, + }), + ).toBe(true); + }); + + it('should detect webview while `app-version` header is present', () => { + expect( + isWebviewEnvironment({ + headers: { + 'app-version': '10.0.0', + 'user-agent': UA_IPHONE, + }, + query: {}, + }), + ).toBe(true); + }); +}); + +describe('isAlfaMobileWebviewByUserAgent', () => { + it('should return false if webview while UA is not matched to webview-UA', () => { + expect(isWebviewByUserAgent(UA_IPHONE, undefined)).toBeFalsy(); + }); + + it('should return true if webview while UA is matched to webview-UA', () => { + expect(isWebviewByUserAgent(UA_WEBVIEW, undefined)).toBe(true); + }); + + it('should return true if webview while `app-version` header is present', () => { + expect(isWebviewByUserAgent(UA_IPHONE, '10.0.0')).toBe(true); + }); +}); diff --git a/test/server/utils.test.ts b/test/shared/utils.test.ts similarity index 54% rename from test/server/utils.test.ts rename to test/shared/utils.test.ts index 1fd585a..3a019b6 100644 --- a/test/server/utils.test.ts +++ b/test/shared/utils.test.ts @@ -1,46 +1,37 @@ -import { extractNativeParamsFromCookies } from '../../src/server/utils'; +import { extractNativeParamsFromCookies } from '../../src/shared/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({}); + const result = extractNativeParamsFromCookies(); + expect(result).toEqual(null); }); it('should return an empty object if native params cookie is not present', () => { - const request = { headers: { cookie: 'otherCookie=value' } }; - const result = extractNativeParamsFromCookies(request); + const result = extractNativeParamsFromCookies('otherCookie=value'); 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); + const result = extractNativeParamsFromCookies(`${NATIVE_PARAMS_COOKIE_NAME}=${encodedNativeParams}`); 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); + const result = extractNativeParamsFromCookies(`${NATIVE_PARAMS_COOKIE_NAME}=${invalidJsonValue}`); 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); + const result = extractNativeParamsFromCookies(`otherCookie=value; ${NATIVE_PARAMS_COOKIE_NAME}=${encodedNativeParams}; anotherCookie=anotherValue`); expect(result).toEqual(nativeParams); }); });