chore(suite-native): use universal links for prod app

This commit is contained in:
vytick
2026-02-18 17:45:28 +04:00
parent d8b175d26b
commit d7a9cef799
7 changed files with 243 additions and 105 deletions

View File

@@ -248,6 +248,11 @@ export default ({ config }: ConfigContext): ExpoConfig => {
host: 'trezor.io',
pathPattern: '/setup/.*',
},
{
scheme: 'https',
host: 'trezor.io',
pathPattern: '/suite/deeplink/.*',
},
]
: [
{

View File

@@ -21,6 +21,7 @@
"@suite-common/trading": "workspace:*",
"@suite-common/wallet-core": "workspace:*",
"@suite-native/atoms": "workspace:*",
"@suite-native/config": "workspace:*",
"@suite-native/feature-flags": "workspace:*",
"@suite-native/intl": "workspace:*",
"@suite-native/sentry": "workspace:*",

View File

@@ -1,2 +1,9 @@
export const TRADING_URL_BASE = 'trezorsuite://trading';
import { isProduction } from '@suite-native/config';
const TRADING_URL_BASE_PRODUCTION = 'https://trezor.io/suite/deeplink/trade';
const TRADING_URL_BASE_DEV = 'trezorsuite://trading';
// we don't want to expose custom scheme in production,
// but we want to use custom scheme for develop as dev app has different app package
export const TRADING_URL_BASE = isProduction() ? TRADING_URL_BASE_PRODUCTION : TRADING_URL_BASE_DEV;
export const TRADING_URL_DEFAULT_BACK = `${TRADING_URL_BASE}/back`;

View File

@@ -1,16 +1,60 @@
import type { FormResponse } from 'invity-api';
import { trezorLogo } from '@suite-common/suite-constants';
import {
applyHtmlTemplate,
buildTradingUrl,
getRequestFormSource,
getSourceForForm,
} from '../formUtils';
import type { BuildTradingUrlProps } from '../formUtils';
const mockIsProduction = jest.fn();
jest.mock('@suite-native/config', () => ({
isProduction: () => mockIsProduction(),
}));
const importModules = () => {
const { TRADING_URL_BASE, TRADING_URL_DEFAULT_BACK } = require('../../consts');
const {
applyHtmlTemplate,
buildTradingUrl,
getRequestFormSource,
getSourceForForm,
} = require('../formUtils');
return {
TRADING_URL_BASE: TRADING_URL_BASE as string,
TRADING_URL_DEFAULT_BACK: TRADING_URL_DEFAULT_BACK as string,
applyHtmlTemplate: applyHtmlTemplate as (
content?: string,
options?: Record<string, string>,
) => string,
buildTradingUrl: buildTradingUrl as (props: BuildTradingUrlProps) => string,
getRequestFormSource: getRequestFormSource as (args: {
form?: FormResponse['form'];
}) => { uri?: string; html?: string } | null,
getSourceForForm: getSourceForForm as (
form: FormResponse['form'] | undefined,
backUrl?: string,
) => { uri?: string; html?: string } | null,
};
};
describe('formUtils', () => {
describe('applyHtmlTemplate', () => {
it('should have content', () => {
expect(applyHtmlTemplate('CONTENT_TO_EMBED')).toBe(`
describe('dev environment', () => {
let applyHtmlTemplate: ReturnType<typeof importModules>['applyHtmlTemplate'];
let buildTradingUrl: ReturnType<typeof importModules>['buildTradingUrl'];
let getRequestFormSource: ReturnType<typeof importModules>['getRequestFormSource'];
let getSourceForForm: ReturnType<typeof importModules>['getSourceForForm'];
beforeAll(() => {
mockIsProduction.mockReturnValue(false);
jest.isolateModules(() => {
({ applyHtmlTemplate, buildTradingUrl, getRequestFormSource, getSourceForForm } =
importModules());
});
});
describe('applyHtmlTemplate', () => {
it('should have content with dev back URL', () => {
expect(applyHtmlTemplate('CONTENT_TO_EMBED')).toBe(`
<!DOCTYPE html>
<html>
<head>
@@ -48,16 +92,16 @@ ${' '.repeat(16)}
</body>
</html>
`);
});
});
it('should have content with options', () => {
expect(
applyHtmlTemplate('CONTENT_TO_EMBED', {
title: 'TITLE',
script: 'SCRIPT',
backUrl: 'BACK_URL',
}),
).toStrictEqual(`
it('should have content with options', () => {
expect(
applyHtmlTemplate('CONTENT_TO_EMBED', {
title: 'TITLE',
script: 'SCRIPT',
backUrl: 'BACK_URL',
}),
).toStrictEqual(`
<!DOCTYPE html>
<html>
<head>
@@ -95,101 +139,132 @@ ${' '.repeat(16)}
</body>
</html>
`);
});
});
describe('getRequestFormSource', () => {
it('should return null when no form is provided', () => {
expect(getRequestFormSource({})).toBeNull();
});
it('should return uri for GET formMethod', () => {
expect(
getRequestFormSource({
form: {
formMethod: 'GET',
formAction: 'get_action',
fields: {},
},
}),
).toStrictEqual({
uri: 'get_action',
});
});
it('should return null for IFRAME formMethod', () => {
expect(
getRequestFormSource({
form: {
formMethod: 'IFRAME',
formAction: 'get_action',
fields: {},
},
}),
).toBeNull();
});
describe('getRequestFormSource', () => {
it('should return null when no form is provided', () => {
expect(getRequestFormSource({})).toBeNull();
});
it('should create script with form for POST formMethod', () => {
expect(
getRequestFormSource({
form: {
formMethod: 'POST',
formAction: 'post_action',
fields: { key1: 'value1', key2: 'value2' },
},
}),
).toStrictEqual({
html: `
it('should return uri for GET formMethod', () => {
expect(
getRequestFormSource({
form: {
formMethod: 'GET',
formAction: 'get_action',
fields: {},
},
}),
).toStrictEqual({
uri: 'get_action',
});
});
it('should return null for IFRAME formMethod', () => {
expect(
getRequestFormSource({
form: {
formMethod: 'IFRAME',
formAction: 'get_action',
fields: {},
},
}),
).toBeNull();
});
it('should create script with form for POST formMethod', () => {
expect(
getRequestFormSource({
form: {
formMethod: 'POST',
formAction: 'post_action',
fields: { key1: 'value1', key2: 'value2' },
},
}),
).toStrictEqual({
html: `
Forwarding to post_action...
<form id="buy-form" method="POST" action="post_action" target='_self'>
<input type="hidden" name="key1" value="value1"><input type="hidden" name="key2" value="value2">
</form>
<script type="text/javascript">document.getElementById("buy-form").submit();</script>`,
});
});
});
describe('getSourceForForm', () => {
it('should return null when no form is provided', () => {
expect(getSourceForForm(undefined)).toBeNull();
});
it('should return uri object for GET form', () => {
expect(
getSourceForForm({
formMethod: 'GET',
formAction: 'get_action',
fields: {},
}),
).toStrictEqual({
uri: 'get_action',
});
});
it('should return html object for POST form', () => {
const result = getSourceForForm(
{
formMethod: 'POST',
formAction: 'post_action',
fields: { key: 'value' },
},
'custom_back_url',
);
expect(result).toHaveProperty('html');
expect(result?.html).toContain('post_action');
expect(result?.html).toContain('custom_back_url');
});
});
describe('buildTradingUrl', () => {
it('should return correct url format with dev base', () => {
expect(
buildTradingUrl({
actionType: 'quote',
tradeType: 'buy',
orderId: '1234',
}),
).toBe('trezorsuite://trading?action=quote&tradeType=buy&orderId=1234');
});
});
});
describe('getSourceForForm', () => {
it('should return null when no form is provided', () => {
expect(getSourceForForm(undefined)).toBeNull();
});
describe('production environment', () => {
let applyHtmlTemplate: ReturnType<typeof importModules>['applyHtmlTemplate'];
let buildTradingUrl: ReturnType<typeof importModules>['buildTradingUrl'];
it('should return uri object for GET form', () => {
expect(
getSourceForForm({
formMethod: 'GET',
formAction: 'get_action',
fields: {},
}),
).toStrictEqual({
uri: 'get_action',
beforeAll(() => {
mockIsProduction.mockReturnValue(true);
jest.isolateModules(() => {
({ applyHtmlTemplate, buildTradingUrl } = importModules());
});
});
it('should return html object for POST form', () => {
const result = getSourceForForm(
{
formMethod: 'POST',
formAction: 'post_action',
fields: { key: 'value' },
},
'custom_back_url',
);
expect(result).toHaveProperty('html');
expect(result?.html).toContain('post_action');
expect(result?.html).toContain('custom_back_url');
it('should use production back URL in applyHtmlTemplate', () => {
const html = applyHtmlTemplate('CONTENT_TO_EMBED');
expect(html).toContain('href="https://trezor.io/suite/deeplink/trade/back"');
});
});
describe('buildTradingUrl', () => {
it('should return correct url format', () => {
it('should return correct url format with production base', () => {
expect(
buildTradingUrl({
actionType: 'quote',
tradeType: 'buy',
orderId: '1234',
}),
).toBe('trezorsuite://trading?action=quote&tradeType=buy&orderId=1234');
).toBe(
'https://trezor.io/suite/deeplink/trade?action=quote&tradeType=buy&orderId=1234',
);
});
});
});

View File

@@ -1,43 +1,91 @@
import { TRADING_URL_DEFAULT_BACK } from '../../consts';
import { doesUrlContainCloseCallbackUrl } from '../utils';
const mockIsProduction = jest.fn();
jest.mock('@suite-native/config', () => ({
isProduction: () => mockIsProduction(),
}));
const importModules = () => {
const { TRADING_URL_BASE, TRADING_URL_DEFAULT_BACK } = require('../../consts');
const { doesUrlContainCloseCallbackUrl } = require('../utils');
return { TRADING_URL_BASE, TRADING_URL_DEFAULT_BACK, doesUrlContainCloseCallbackUrl };
};
describe('utils', () => {
describe('doesUrlContainCloseCallbackUrl', () => {
const closeCallbackUrl = 'trezorsuite://trading';
describe('doesUrlContainCloseCallbackUrl - dev', () => {
let doesUrlContainCloseCallbackUrl: (url: string, closeCallbackUrl?: string) => boolean;
it('should return true when URL contains closeCallbackUrl', () => {
const url = 'trezorsuite://trading?action=trade&tradeType=buy&orderId=123';
expect(doesUrlContainCloseCallbackUrl(url, closeCallbackUrl)).toBe(true);
beforeAll(() => {
mockIsProduction.mockReturnValue(false);
jest.isolateModules(() => {
({ doesUrlContainCloseCallbackUrl } = importModules());
});
});
it('should return true when URL contains TRADING_URL_DEFAULT_BACK', () => {
const url = `${TRADING_URL_DEFAULT_BACK}?action=trade&tradeType=buy&orderId=123`;
it('should return true when URL contains dev base', () => {
const url = 'trezorsuite://trading?action=trade&tradeType=buy&orderId=123';
expect(doesUrlContainCloseCallbackUrl(url, 'trezorsuite://trading')).toBe(true);
});
it('should return true when URL contains dev default back', () => {
const url = 'trezorsuite://trading/back?action=trade&tradeType=buy&orderId=123';
expect(doesUrlContainCloseCallbackUrl(url)).toBe(true);
});
it('should return false when URL does not contain TRADING_URL_DEFAULT_BACK', () => {
it('should return false when URL does not match', () => {
const url = 'https://example.com/trading?action=trade&tradeType=buy&orderId=123';
expect(doesUrlContainCloseCallbackUrl(url)).toBe(false);
});
it('should return false when URL does not contain closeCallbackUrl', () => {
const url = 'https://example.com/trading?action=trade&tradeType=buy&orderId=123';
expect(doesUrlContainCloseCallbackUrl(url, closeCallbackUrl)).toBe(false);
expect(doesUrlContainCloseCallbackUrl(url, 'trezorsuite://trading')).toBe(false);
});
it('should handle empty URL', () => {
expect(doesUrlContainCloseCallbackUrl('', closeCallbackUrl)).toBe(false);
expect(doesUrlContainCloseCallbackUrl('', 'trezorsuite://trading')).toBe(false);
});
it('should handle URL with special characters', () => {
const url =
'trezorsuite://trading?action=trade&tradeType=buy&orderId=dd070b73-fe29-4769-8be1-4075d6b43265&transactionId=8c9476a7-958b-412b-a378-3a3f59b6105a&baseCurrencyCode=czk&baseCurrencyAmount=384.78&transactionStatus=completed';
expect(doesUrlContainCloseCallbackUrl(url, closeCallbackUrl)).toBe(true);
expect(doesUrlContainCloseCallbackUrl(url, 'trezorsuite://trading')).toBe(true);
});
});
describe('doesUrlContainCloseCallbackUrl - production', () => {
let TRADING_URL_BASE: string;
let TRADING_URL_DEFAULT_BACK: string;
let doesUrlContainCloseCallbackUrl: (url: string, closeCallbackUrl?: string) => boolean;
beforeAll(() => {
mockIsProduction.mockReturnValue(true);
jest.isolateModules(() => {
({ TRADING_URL_BASE, TRADING_URL_DEFAULT_BACK, doesUrlContainCloseCallbackUrl } =
importModules());
});
});
it('should handle url without specifying closeCallbackUrl', () => {
const url = `${TRADING_URL_DEFAULT_BACK}?action=trade&tradeType=buy&orderId=123`;
it('should use production URL base', () => {
expect(TRADING_URL_BASE).toBe('https://trezor.io/suite/deeplink/trade');
expect(TRADING_URL_DEFAULT_BACK).toBe('https://trezor.io/suite/deeplink/trade/back');
});
it('should return true when URL contains production base', () => {
const url =
'https://trezor.io/suite/deeplink/trade?action=trade&tradeType=buy&orderId=123';
expect(doesUrlContainCloseCallbackUrl(url, TRADING_URL_BASE)).toBe(true);
});
it('should return true when URL contains production default back', () => {
const url =
'https://trezor.io/suite/deeplink/trade/back?action=trade&tradeType=buy&orderId=123';
expect(doesUrlContainCloseCallbackUrl(url)).toBe(true);
});
it('should not match dev URL with production base', () => {
const url = 'trezorsuite://trading?action=trade&tradeType=buy&orderId=123';
expect(doesUrlContainCloseCallbackUrl(url, TRADING_URL_BASE)).toBe(false);
});
});
});

View File

@@ -13,6 +13,7 @@
"path": "../../suite-common/wallet-core"
},
{ "path": "../atoms" },
{ "path": "../config" },
{ "path": "../feature-flags" },
{ "path": "../intl" },
{ "path": "../sentry" },

View File

@@ -14253,6 +14253,7 @@ __metadata:
"@suite-common/trading": "workspace:*"
"@suite-common/wallet-core": "workspace:*"
"@suite-native/atoms": "workspace:*"
"@suite-native/config": "workspace:*"
"@suite-native/feature-flags": "workspace:*"
"@suite-native/intl": "workspace:*"
"@suite-native/sentry": "workspace:*"