@linkforty/core 1.44.1 → 1.44.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/dist/index.config.test.d.ts +0 -2
- package/dist/index.config.test.d.ts.map +0 -1
- package/dist/index.config.test.js +0 -87
- package/dist/index.config.test.js.map +0 -1
- package/dist/lib/bot-detection.test.d.ts +0 -2
- package/dist/lib/bot-detection.test.d.ts.map +0 -1
- package/dist/lib/bot-detection.test.js +0 -65
- package/dist/lib/bot-detection.test.js.map +0 -1
- package/dist/lib/client-ip.test.d.ts +0 -7
- package/dist/lib/client-ip.test.d.ts.map +0 -1
- package/dist/lib/client-ip.test.js +0 -114
- package/dist/lib/client-ip.test.js.map +0 -1
- package/dist/lib/config.test.d.ts +0 -2
- package/dist/lib/config.test.d.ts.map +0 -1
- package/dist/lib/config.test.js +0 -126
- package/dist/lib/config.test.js.map +0 -1
- package/dist/lib/database.test.d.ts +0 -2
- package/dist/lib/database.test.d.ts.map +0 -1
- package/dist/lib/database.test.js +0 -180
- package/dist/lib/database.test.js.map +0 -1
- package/dist/lib/deep-link-payload.test.d.ts +0 -2
- package/dist/lib/deep-link-payload.test.d.ts.map +0 -1
- package/dist/lib/deep-link-payload.test.js +0 -202
- package/dist/lib/deep-link-payload.test.js.map +0 -1
- package/dist/lib/event-emitter.test.d.ts +0 -2
- package/dist/lib/event-emitter.test.d.ts.map +0 -1
- package/dist/lib/event-emitter.test.js +0 -162
- package/dist/lib/event-emitter.test.js.map +0 -1
- package/dist/lib/fingerprint.test.d.ts +0 -2
- package/dist/lib/fingerprint.test.d.ts.map +0 -1
- package/dist/lib/fingerprint.test.js +0 -750
- package/dist/lib/fingerprint.test.js.map +0 -1
- package/dist/lib/launchpad.test.d.ts +0 -2
- package/dist/lib/launchpad.test.d.ts.map +0 -1
- package/dist/lib/launchpad.test.js +0 -368
- package/dist/lib/launchpad.test.js.map +0 -1
- package/dist/lib/link-lookup.test.d.ts +0 -2
- package/dist/lib/link-lookup.test.d.ts.map +0 -1
- package/dist/lib/link-lookup.test.js +0 -61
- package/dist/lib/link-lookup.test.js.map +0 -1
- package/dist/lib/link-resolution-cache.test.d.ts +0 -2
- package/dist/lib/link-resolution-cache.test.d.ts.map +0 -1
- package/dist/lib/link-resolution-cache.test.js +0 -45
- package/dist/lib/link-resolution-cache.test.js.map +0 -1
- package/dist/lib/link-safety.test.d.ts +0 -2
- package/dist/lib/link-safety.test.d.ts.map +0 -1
- package/dist/lib/link-safety.test.js +0 -211
- package/dist/lib/link-safety.test.js.map +0 -1
- package/dist/lib/not-found-page.test.d.ts +0 -2
- package/dist/lib/not-found-page.test.d.ts.map +0 -1
- package/dist/lib/not-found-page.test.js +0 -151
- package/dist/lib/not-found-page.test.js.map +0 -1
- package/dist/lib/portable/cli.test.d.ts +0 -2
- package/dist/lib/portable/cli.test.d.ts.map +0 -1
- package/dist/lib/portable/cli.test.js +0 -98
- package/dist/lib/portable/cli.test.js.map +0 -1
- package/dist/lib/portable/portable.integration.test.d.ts +0 -2
- package/dist/lib/portable/portable.integration.test.d.ts.map +0 -1
- package/dist/lib/portable/portable.integration.test.js +0 -139
- package/dist/lib/portable/portable.integration.test.js.map +0 -1
- package/dist/lib/portable/registry.schema.test.d.ts +0 -2
- package/dist/lib/portable/registry.schema.test.d.ts.map +0 -1
- package/dist/lib/portable/registry.schema.test.js +0 -59
- package/dist/lib/portable/registry.schema.test.js.map +0 -1
- package/dist/lib/scanner-detection.test.d.ts +0 -2
- package/dist/lib/scanner-detection.test.d.ts.map +0 -1
- package/dist/lib/scanner-detection.test.js +0 -124
- package/dist/lib/scanner-detection.test.js.map +0 -1
- package/dist/lib/schema-guard.test.d.ts +0 -2
- package/dist/lib/schema-guard.test.d.ts.map +0 -1
- package/dist/lib/schema-guard.test.js +0 -83
- package/dist/lib/schema-guard.test.js.map +0 -1
- package/dist/lib/utils.test.d.ts +0 -2
- package/dist/lib/utils.test.d.ts.map +0 -1
- package/dist/lib/utils.test.js +0 -261
- package/dist/lib/utils.test.js.map +0 -1
- package/dist/lib/webhook.test.d.ts +0 -2
- package/dist/lib/webhook.test.d.ts.map +0 -1
- package/dist/lib/webhook.test.js +0 -340
- package/dist/lib/webhook.test.js.map +0 -1
- package/dist/routes/health.test.d.ts +0 -2
- package/dist/routes/health.test.d.ts.map +0 -1
- package/dist/routes/health.test.js +0 -82
- package/dist/routes/health.test.js.map +0 -1
- package/dist/routes/preview.test.d.ts +0 -2
- package/dist/routes/preview.test.d.ts.map +0 -1
- package/dist/routes/preview.test.js +0 -111
- package/dist/routes/preview.test.js.map +0 -1
- package/dist/routes/qr.test.d.ts +0 -2
- package/dist/routes/qr.test.d.ts.map +0 -1
- package/dist/routes/qr.test.js +0 -67
- package/dist/routes/qr.test.js.map +0 -1
- package/dist/routes/redirect.click-event.test.d.ts +0 -2
- package/dist/routes/redirect.click-event.test.d.ts.map +0 -1
- package/dist/routes/redirect.click-event.test.js +0 -85
- package/dist/routes/redirect.click-event.test.js.map +0 -1
- package/dist/routes/redirect.host-scope.integration.test.d.ts +0 -2
- package/dist/routes/redirect.host-scope.integration.test.d.ts.map +0 -1
- package/dist/routes/redirect.host-scope.integration.test.js +0 -105
- package/dist/routes/redirect.host-scope.integration.test.js.map +0 -1
- package/dist/routes/redirect.interstitial.test.d.ts +0 -2
- package/dist/routes/redirect.interstitial.test.d.ts.map +0 -1
- package/dist/routes/redirect.interstitial.test.js +0 -128
- package/dist/routes/redirect.interstitial.test.js.map +0 -1
- package/dist/routes/redirect.launchpad.test.d.ts +0 -2
- package/dist/routes/redirect.launchpad.test.d.ts.map +0 -1
- package/dist/routes/redirect.launchpad.test.js +0 -515
- package/dist/routes/redirect.launchpad.test.js.map +0 -1
- package/dist/routes/redirect.no-web-destination.test.d.ts +0 -2
- package/dist/routes/redirect.no-web-destination.test.d.ts.map +0 -1
- package/dist/routes/redirect.no-web-destination.test.js +0 -176
- package/dist/routes/redirect.no-web-destination.test.js.map +0 -1
- package/dist/routes/redirect.not-found.test.d.ts +0 -2
- package/dist/routes/redirect.not-found.test.d.ts.map +0 -1
- package/dist/routes/redirect.not-found.test.js +0 -280
- package/dist/routes/redirect.not-found.test.js.map +0 -1
- package/dist/routes/redirect.safety.test.d.ts +0 -2
- package/dist/routes/redirect.safety.test.d.ts.map +0 -1
- package/dist/routes/redirect.safety.test.js +0 -284
- package/dist/routes/redirect.safety.test.js.map +0 -1
- package/dist/routes/redirect.store-url.test.d.ts +0 -2
- package/dist/routes/redirect.store-url.test.d.ts.map +0 -1
- package/dist/routes/redirect.store-url.test.js +0 -239
- package/dist/routes/redirect.store-url.test.js.map +0 -1
- package/dist/routes/redirect.test.d.ts +0 -2
- package/dist/routes/redirect.test.d.ts.map +0 -1
- package/dist/routes/redirect.test.js +0 -158
- package/dist/routes/redirect.test.js.map +0 -1
- package/dist/routes/redirect.web-link.test.d.ts +0 -2
- package/dist/routes/redirect.web-link.test.d.ts.map +0 -1
- package/dist/routes/redirect.web-link.test.js +0 -138
- package/dist/routes/redirect.web-link.test.js.map +0 -1
- package/dist/routes/sdk-cache-bypass.test.d.ts +0 -2
- package/dist/routes/sdk-cache-bypass.test.d.ts.map +0 -1
- package/dist/routes/sdk-cache-bypass.test.js +0 -175
- package/dist/routes/sdk-cache-bypass.test.js.map +0 -1
- package/dist/routes/sdk.device-values.integration.test.d.ts +0 -2
- package/dist/routes/sdk.device-values.integration.test.d.ts.map +0 -1
- package/dist/routes/sdk.device-values.integration.test.js +0 -132
- package/dist/routes/sdk.device-values.integration.test.js.map +0 -1
- package/dist/routes/sdk.event.test.d.ts +0 -2
- package/dist/routes/sdk.event.test.d.ts.map +0 -1
- package/dist/routes/sdk.event.test.js +0 -284
- package/dist/routes/sdk.event.test.js.map +0 -1
- package/dist/routes/sdk.install-certainty.integration.test.d.ts +0 -2
- package/dist/routes/sdk.install-certainty.integration.test.d.ts.map +0 -1
- package/dist/routes/sdk.install-certainty.integration.test.js +0 -104
- package/dist/routes/sdk.install-certainty.integration.test.js.map +0 -1
- package/dist/routes/sdk.install-live-links.integration.test.d.ts +0 -2
- package/dist/routes/sdk.install-live-links.integration.test.d.ts.map +0 -1
- package/dist/routes/sdk.install-live-links.integration.test.js +0 -92
- package/dist/routes/sdk.install-live-links.integration.test.js.map +0 -1
- package/dist/routes/sdk.install-referrer.integration.test.d.ts +0 -2
- package/dist/routes/sdk.install-referrer.integration.test.d.ts.map +0 -1
- package/dist/routes/sdk.install-referrer.integration.test.js +0 -187
- package/dist/routes/sdk.install-referrer.integration.test.js.map +0 -1
- package/dist/routes/sdk.install-window-once.integration.test.d.ts +0 -2
- package/dist/routes/sdk.install-window-once.integration.test.d.ts.map +0 -1
- package/dist/routes/sdk.install-window-once.integration.test.js +0 -147
- package/dist/routes/sdk.install-window-once.integration.test.js.map +0 -1
- package/dist/routes/sdk.install.test.d.ts +0 -2
- package/dist/routes/sdk.install.test.d.ts.map +0 -1
- package/dist/routes/sdk.install.test.js +0 -99
- package/dist/routes/sdk.install.test.js.map +0 -1
- package/dist/routes/sdk.resolve-click.test.d.ts +0 -2
- package/dist/routes/sdk.resolve-click.test.d.ts.map +0 -1
- package/dist/routes/sdk.resolve-click.test.js +0 -79
- package/dist/routes/sdk.resolve-click.test.js.map +0 -1
- package/dist/routes/sdk.resolve-scope.test.d.ts +0 -2
- package/dist/routes/sdk.resolve-scope.test.d.ts.map +0 -1
- package/dist/routes/sdk.resolve-scope.test.js +0 -105
- package/dist/routes/sdk.resolve-scope.test.js.map +0 -1
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The mobile scheme interstitial: the page a phone gets when a link has a URI
|
|
3
|
-
* scheme, which tries the app and falls back to the store.
|
|
4
|
-
*
|
|
5
|
-
* Its two URLs used to be interpolated into JavaScript source with `"` and `<`
|
|
6
|
-
* replaced — enough to keep a value inside its string literal only until the
|
|
7
|
-
* value contained a backslash or a line terminator. They now reach the document
|
|
8
|
-
* as attributes and the script reads them back, so the JavaScript contains no
|
|
9
|
-
* configured value at all.
|
|
10
|
-
*/
|
|
11
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
12
|
-
import Fastify from 'fastify';
|
|
13
|
-
import { redirectRoutes } from './redirect.js';
|
|
14
|
-
import { safeSchemeHref } from '../lib/link-safety.js';
|
|
15
|
-
const query = vi.fn();
|
|
16
|
-
vi.mock('../lib/database.js', () => ({
|
|
17
|
-
db: {
|
|
18
|
-
query: (...args) => query(...args),
|
|
19
|
-
},
|
|
20
|
-
}));
|
|
21
|
-
const IPHONE_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
|
|
22
|
-
function linkRow(overrides = {}) {
|
|
23
|
-
return {
|
|
24
|
-
id: '00000000-0000-0000-0000-0000000000aa',
|
|
25
|
-
short_code: 'abc123',
|
|
26
|
-
title: null,
|
|
27
|
-
og_title: null,
|
|
28
|
-
original_url: null,
|
|
29
|
-
web_fallback_url: null,
|
|
30
|
-
ios_app_store_url: 'https://apps.apple.com/app/id1',
|
|
31
|
-
android_app_store_url: null,
|
|
32
|
-
ios_universal_link: null,
|
|
33
|
-
android_app_link: null,
|
|
34
|
-
app_scheme: 'demo',
|
|
35
|
-
deep_link_path: '/p/1',
|
|
36
|
-
custom_scheme_url: null,
|
|
37
|
-
launchpad_mode: 'inherit',
|
|
38
|
-
is_active: true,
|
|
39
|
-
warn_at: null,
|
|
40
|
-
owner_suspended_at: null,
|
|
41
|
-
expires_at: null,
|
|
42
|
-
targeting_rules: null,
|
|
43
|
-
template_settings: null,
|
|
44
|
-
org_settings: null,
|
|
45
|
-
utm_parameters: null,
|
|
46
|
-
deep_link_parameters: null,
|
|
47
|
-
append_click_id: false,
|
|
48
|
-
...overrides,
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
function mockDb(row) {
|
|
52
|
-
query.mockReset();
|
|
53
|
-
query.mockImplementation(async (sql) => {
|
|
54
|
-
if (/information_schema\.columns/i.test(sql))
|
|
55
|
-
return { rows: [{ '?column?': 1 }], rowCount: 1 };
|
|
56
|
-
if (/FROM links l/i.test(sql))
|
|
57
|
-
return { rows: row ? [row] : [], rowCount: row ? 1 : 0 };
|
|
58
|
-
return { rows: [], rowCount: 0 };
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
const get = (app) => app.inject({ method: 'GET', url: '/abc123', headers: { 'user-agent': IPHONE_UA } });
|
|
62
|
-
let app;
|
|
63
|
-
beforeEach(async () => {
|
|
64
|
-
app = Fastify();
|
|
65
|
-
await app.register(redirectRoutes);
|
|
66
|
-
await app.ready();
|
|
67
|
-
});
|
|
68
|
-
afterEach(async () => {
|
|
69
|
-
await new Promise((r) => setImmediate(r));
|
|
70
|
-
await app.close();
|
|
71
|
-
});
|
|
72
|
-
describe('scheme interstitial', () => {
|
|
73
|
-
it('serves the interstitial with the scheme and store URLs as attributes, and no configured value inside the script', async () => {
|
|
74
|
-
mockDb(linkRow({ deep_link_parameters: { x: '1', y: 'two words' } }));
|
|
75
|
-
const res = await get(app);
|
|
76
|
-
expect(res.statusCode).toBe(200);
|
|
77
|
-
expect(res.body).toContain('id="open-btn" href="demo://p/1?x=1&y=two+words"');
|
|
78
|
-
expect(res.body).toContain('id="store-btn" href="https://apps.apple.com/app/id1"');
|
|
79
|
-
const script = res.body.slice(res.body.indexOf('<script>'), res.body.indexOf('</script>'));
|
|
80
|
-
expect(script).not.toContain('demo://');
|
|
81
|
-
expect(script).not.toContain('apps.apple.com');
|
|
82
|
-
expect(script).toContain("openBtn.getAttribute('href') + hash");
|
|
83
|
-
expect(script).toContain("window.location.replace(storeBtn.getAttribute('href'))");
|
|
84
|
-
});
|
|
85
|
-
/**
|
|
86
|
-
* A custom scheme URL is operator-configured free text. A backslash before the
|
|
87
|
-
* closing quote, or a line terminator, used to end the JavaScript string
|
|
88
|
-
* literal early and take the whole script — auto-open and fallback timer —
|
|
89
|
-
* with it. Now it is an attribute value, escaped like any other.
|
|
90
|
-
*/
|
|
91
|
-
it('keeps a scheme URL with a quote, a backslash and a line separator out of the script', async () => {
|
|
92
|
-
mockDb(linkRow({ custom_scheme_url: 'demo://open?q="x"\\
</script><img src=x onerror=alert(1)>' }));
|
|
93
|
-
const res = await get(app);
|
|
94
|
-
expect(res.statusCode).toBe(200);
|
|
95
|
-
expect(res.body.match(/<\/script>/g)).toHaveLength(1);
|
|
96
|
-
expect(res.body).not.toContain('<img src=x');
|
|
97
|
-
expect(res.body).toContain('href="demo://open?q="x"\\
</script><img src=x onerror=alert(1)>"');
|
|
98
|
-
});
|
|
99
|
-
it('escapes an ampersand in the title, which the old generator left raw', async () => {
|
|
100
|
-
mockDb(linkRow({ title: 'Cats & Dogs <3' }));
|
|
101
|
-
const res = await get(app);
|
|
102
|
-
expect(res.body).toContain('<h1>Opening Cats & Dogs <3...</h1>');
|
|
103
|
-
});
|
|
104
|
-
it('treats an executable "scheme" as no scheme: store redirect, no interstitial, no scheme in Location', async () => {
|
|
105
|
-
mockDb(linkRow({ app_scheme: 'javascript', deep_link_path: '/%0aalert(1)' }));
|
|
106
|
-
const res = await get(app);
|
|
107
|
-
expect(res.statusCode).toBe(302);
|
|
108
|
-
expect(res.headers.location).toBe('https://apps.apple.com/app/id1');
|
|
109
|
-
expect(res.body).not.toContain('open-btn');
|
|
110
|
-
});
|
|
111
|
-
it('refuses an executable custom scheme URL even when app_scheme itself is fine', async () => {
|
|
112
|
-
mockDb(linkRow({ custom_scheme_url: 'javascript:alert(1)' }));
|
|
113
|
-
const res = await get(app);
|
|
114
|
-
expect(res.statusCode).toBe(302);
|
|
115
|
-
expect(res.headers.location).toBe('demo://p/1');
|
|
116
|
-
});
|
|
117
|
-
});
|
|
118
|
-
describe('safeSchemeHref', () => {
|
|
119
|
-
it('accepts app schemes and rejects the ones a browser executes', () => {
|
|
120
|
-
expect(safeSchemeHref('demo://p/1')).toBe('demo://p/1');
|
|
121
|
-
expect(safeSchemeHref('com.example.app://p/1')).toBe('com.example.app://p/1');
|
|
122
|
-
expect(safeSchemeHref('https://example.com/p')).toBe('https://example.com/p');
|
|
123
|
-
for (const bad of ['javascript:alert(1)', 'JavaScript://%0aalert(1)', 'data:text/html,hi', 'vbscript:x', 'file:///etc/passwd', 'blob:x', 'about:blank', 'no-scheme', '']) {
|
|
124
|
-
expect(safeSchemeHref(bad)).toBeNull();
|
|
125
|
-
}
|
|
126
|
-
});
|
|
127
|
-
});
|
|
128
|
-
//# sourceMappingURL=redirect.interstitial.test.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"redirect.interstitial.test.js","sourceRoot":"","sources":["../../src/routes/redirect.interstitial.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"redirect.launchpad.test.d.ts","sourceRoot":"","sources":["../../src/routes/redirect.launchpad.test.ts"],"names":[],"mappings":""}
|
|
@@ -1,515 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Route-level tests for the launchpad page: when the redirect serves it, what it
|
|
3
|
-
* carries, and — most importantly — when it does not.
|
|
4
|
-
*
|
|
5
|
-
* Follows redirect.no-web-destination.test.ts: the real `redirectRoutes` plugin
|
|
6
|
-
* through `fastify.inject()`, only the data layer mocked.
|
|
7
|
-
*/
|
|
8
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
9
|
-
import Fastify from 'fastify';
|
|
10
|
-
import { redirectRoutes } from './redirect.js';
|
|
11
|
-
const query = vi.fn();
|
|
12
|
-
vi.mock('../lib/database.js', () => ({
|
|
13
|
-
db: {
|
|
14
|
-
query: (...args) => query(...args),
|
|
15
|
-
},
|
|
16
|
-
}));
|
|
17
|
-
const DESKTOP_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
|
|
18
|
-
const IPHONE_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
|
|
19
|
-
const ANDROID_UA = 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36';
|
|
20
|
-
/** Facebook's iOS in-app browser: Universal Links do not fire here. */
|
|
21
|
-
const FB_IOS_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 [FBAN/FBIOS;FBAV/450.0.0.0.0]';
|
|
22
|
-
function linkRow(overrides = {}) {
|
|
23
|
-
return {
|
|
24
|
-
id: '00000000-0000-0000-0000-0000000000aa',
|
|
25
|
-
short_code: 'abc123',
|
|
26
|
-
title: null,
|
|
27
|
-
description: null,
|
|
28
|
-
og_title: null,
|
|
29
|
-
og_description: null,
|
|
30
|
-
og_image_url: null,
|
|
31
|
-
original_url: null,
|
|
32
|
-
web_fallback_url: null,
|
|
33
|
-
ios_app_store_url: 'https://apps.apple.com/app/id1',
|
|
34
|
-
android_app_store_url: 'https://play.google.com/store/apps/details?id=demo',
|
|
35
|
-
ios_universal_link: null,
|
|
36
|
-
android_app_link: null,
|
|
37
|
-
app_scheme: null,
|
|
38
|
-
deep_link_path: null,
|
|
39
|
-
launchpad_mode: 'inherit',
|
|
40
|
-
is_active: true,
|
|
41
|
-
warn_at: null,
|
|
42
|
-
owner_suspended_at: null,
|
|
43
|
-
expires_at: null,
|
|
44
|
-
targeting_rules: null,
|
|
45
|
-
template_settings: null,
|
|
46
|
-
org_settings: null,
|
|
47
|
-
utm_parameters: null,
|
|
48
|
-
deep_link_parameters: null,
|
|
49
|
-
append_click_id: false,
|
|
50
|
-
...overrides,
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
function mockDb(row) {
|
|
54
|
-
query.mockReset();
|
|
55
|
-
query.mockImplementation(async (sql) => {
|
|
56
|
-
if (/information_schema\.columns/i.test(sql)) {
|
|
57
|
-
return { rows: [{ '?column?': 1 }], rowCount: 1 };
|
|
58
|
-
}
|
|
59
|
-
if (/^\s*SELECT\s+l\.\*/i.test(sql) || /FROM links l/i.test(sql)) {
|
|
60
|
-
return { rows: row ? [row] : [], rowCount: row ? 1 : 0 };
|
|
61
|
-
}
|
|
62
|
-
return { rows: [], rowCount: 0 };
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
const get = (app, ua, url = '/abc123') => app.inject({ method: 'GET', url, headers: { 'user-agent': ua, host: 'go.example' } });
|
|
66
|
-
async function build(options = {}) {
|
|
67
|
-
const app = Fastify();
|
|
68
|
-
await app.register(redirectRoutes, options);
|
|
69
|
-
await app.ready();
|
|
70
|
-
return app;
|
|
71
|
-
}
|
|
72
|
-
let app;
|
|
73
|
-
beforeEach(async () => {
|
|
74
|
-
app = await build();
|
|
75
|
-
});
|
|
76
|
-
afterEach(async () => {
|
|
77
|
-
await new Promise((r) => setImmediate(r));
|
|
78
|
-
await app.close();
|
|
79
|
-
});
|
|
80
|
-
describe('launchpad page — when it is served', () => {
|
|
81
|
-
it('replaces the plain page for a desktop visitor to a link with no web destination', async () => {
|
|
82
|
-
mockDb(linkRow({ og_title: 'Example App', og_description: 'A short description of the app' }));
|
|
83
|
-
const res = await get(app, DESKTOP_UA);
|
|
84
|
-
expect(res.statusCode).toBe(200);
|
|
85
|
-
expect(res.headers['content-type']).toMatch(/text\/html/);
|
|
86
|
-
expect(res.headers['x-robots-tag']).toBe('noindex, nofollow');
|
|
87
|
-
expect(res.headers['cache-control']).toBe('no-store');
|
|
88
|
-
expect(res.headers['content-security-policy']).toMatch(/^default-src 'none'; .*script-src 'nonce-[A-Za-z0-9+/=]+'/);
|
|
89
|
-
expect(res.body).toContain('<h1>Example App</h1>');
|
|
90
|
-
expect(res.body).toContain('A short description of the app');
|
|
91
|
-
expect(res.body).toContain('Download on the App Store');
|
|
92
|
-
expect(res.body).toContain('Get it on Google Play');
|
|
93
|
-
expect(res.body).toContain('/api/links/00000000-0000-0000-0000-0000000000aa/qr?format=svg&size=264&url=http%3A%2F%2Fgo.example%2Fabc123"');
|
|
94
|
-
expect(res.body).toContain('<meta property="og:url" content="http://go.example/abc123">');
|
|
95
|
-
expect(res.body).not.toContain('This link opens in an app');
|
|
96
|
-
});
|
|
97
|
-
it('never attempts a scheme open on desktop, and ships no script when nothing needs one', async () => {
|
|
98
|
-
mockDb(linkRow({ app_scheme: 'demo', deep_link_path: '/p/1' }));
|
|
99
|
-
const res = await get(app, DESKTOP_UA);
|
|
100
|
-
expect(res.statusCode).toBe(200);
|
|
101
|
-
expect(res.body).not.toContain('Open in app');
|
|
102
|
-
expect(res.body).not.toContain('<script');
|
|
103
|
-
});
|
|
104
|
-
it('keeps the 302 for a link with a destination when nobody opted in', async () => {
|
|
105
|
-
mockDb(linkRow({ web_fallback_url: 'https://example.com/page' }));
|
|
106
|
-
const res = await get(app, DESKTOP_UA);
|
|
107
|
-
expect(res.statusCode).toBe(302);
|
|
108
|
-
expect(res.headers.location).toBe('https://example.com/page');
|
|
109
|
-
});
|
|
110
|
-
it("serves the page in front of a destination when the workspace chose 'always'", async () => {
|
|
111
|
-
mockDb(linkRow({ web_fallback_url: 'https://example.com/page', org_settings: { launchpad: { desktop: 'always' } } }));
|
|
112
|
-
const res = await get(app, DESKTOP_UA);
|
|
113
|
-
expect(res.statusCode).toBe(200);
|
|
114
|
-
expect(res.body).toContain('class="lp"');
|
|
115
|
-
});
|
|
116
|
-
it("under 'always' the page links to the destination the 302 would have used", async () => {
|
|
117
|
-
mockDb(linkRow({ web_fallback_url: 'https://example.com/page', org_settings: { launchpad: { desktop: 'always' } } }));
|
|
118
|
-
const res = await get(app, DESKTOP_UA);
|
|
119
|
-
expect(res.body).toContain('href="https://example.com/page">Continue on the web</a>');
|
|
120
|
-
mockDb(linkRow({ original_url: 'https://example.com/original', org_settings: { launchpad: { desktop: 'always' } } }));
|
|
121
|
-
const original = await get(app, DESKTOP_UA);
|
|
122
|
-
expect(original.body).toContain('href="https://example.com/original">Continue on the web</a>');
|
|
123
|
-
});
|
|
124
|
-
it('the web link carries the same UTMs, deep-link params and click id the 302 would have', async () => {
|
|
125
|
-
mockDb(linkRow({
|
|
126
|
-
web_fallback_url: 'https://example.com/page',
|
|
127
|
-
utm_parameters: { source: 'newsletter', medium: 'email' },
|
|
128
|
-
deep_link_parameters: { ref: 'abc' },
|
|
129
|
-
append_click_id: true,
|
|
130
|
-
org_settings: { launchpad: { desktop: 'always' } },
|
|
131
|
-
}));
|
|
132
|
-
const res = await get(app, DESKTOP_UA);
|
|
133
|
-
const href = /data-lp-cta="cta_web" href="([^"]+)"/.exec(res.body)?.[1]?.replace(/&/g, '&');
|
|
134
|
-
expect(href).toBeDefined();
|
|
135
|
-
const url = new URL(href);
|
|
136
|
-
expect(url.origin + url.pathname).toBe('https://example.com/page');
|
|
137
|
-
expect(url.searchParams.get('utm_source')).toBe('newsletter');
|
|
138
|
-
expect(url.searchParams.get('utm_medium')).toBe('email');
|
|
139
|
-
expect(url.searchParams.get('ref')).toBe('abc');
|
|
140
|
-
expect(url.searchParams.get('lf_click')).toMatch(/^[0-9a-f-]{36}$/);
|
|
141
|
-
});
|
|
142
|
-
it('has no web link when the link has no web destination', async () => {
|
|
143
|
-
mockDb(linkRow());
|
|
144
|
-
expect((await get(app, DESKTOP_UA)).body).not.toContain('Continue on the web');
|
|
145
|
-
});
|
|
146
|
-
it("a link set to 'off' wins over a workspace set to 'always'", async () => {
|
|
147
|
-
mockDb(linkRow({
|
|
148
|
-
web_fallback_url: 'https://example.com/page',
|
|
149
|
-
launchpad_mode: 'off',
|
|
150
|
-
org_settings: { launchpad: { desktop: 'always' } },
|
|
151
|
-
}));
|
|
152
|
-
const res = await get(app, DESKTOP_UA);
|
|
153
|
-
expect(res.statusCode).toBe(302);
|
|
154
|
-
});
|
|
155
|
-
it("a link set to 'on' wins over the workspace default", async () => {
|
|
156
|
-
mockDb(linkRow({ web_fallback_url: 'https://example.com/page', launchpad_mode: 'on' }));
|
|
157
|
-
const res = await get(app, DESKTOP_UA);
|
|
158
|
-
expect(res.statusCode).toBe(200);
|
|
159
|
-
expect(res.body).toContain('class="lp"');
|
|
160
|
-
});
|
|
161
|
-
it("a workspace set to 'off' gets the plain page for a link with no destination", async () => {
|
|
162
|
-
mockDb(linkRow({ org_settings: { launchpad: { desktop: 'off' } } }));
|
|
163
|
-
const res = await get(app, DESKTOP_UA);
|
|
164
|
-
expect(res.statusCode).toBe(200);
|
|
165
|
-
expect(res.body).toContain('This link opens in an app');
|
|
166
|
-
expect(res.body).not.toContain('class="lp"');
|
|
167
|
-
});
|
|
168
|
-
it('treats a cached row from before the column existed as inherit', async () => {
|
|
169
|
-
const row = linkRow();
|
|
170
|
-
delete row.launchpad_mode;
|
|
171
|
-
mockDb(row);
|
|
172
|
-
const res = await get(app, DESKTOP_UA);
|
|
173
|
-
expect(res.statusCode).toBe(200);
|
|
174
|
-
expect(res.body).toContain('class="lp"');
|
|
175
|
-
});
|
|
176
|
-
it('uses the template slug in og:url for a templated short link', async () => {
|
|
177
|
-
mockDb(linkRow());
|
|
178
|
-
const res = await get(app, DESKTOP_UA, '/promo/abc123');
|
|
179
|
-
expect(res.body).toContain('<meta property="og:url" content="http://go.example/promo/abc123">');
|
|
180
|
-
});
|
|
181
|
-
it('hands the visitor\'s host, template path and query string to the QR code', async () => {
|
|
182
|
-
mockDb(linkRow());
|
|
183
|
-
const res = await get(app, DESKTOP_UA, '/promo/abc123?utm_source=poster&product=42');
|
|
184
|
-
expect(res.body).toContain('&url=http%3A%2F%2Fgo.example%2Fpromo%2Fabc123%3Futm_source%3Dposter%26product%3D42"');
|
|
185
|
-
expect(res.body).toContain('og:url" content="http://go.example/promo/abc123?utm_source=poster&product=42"');
|
|
186
|
-
});
|
|
187
|
-
it('drops a query string too long to fit a scannable code', async () => {
|
|
188
|
-
mockDb(linkRow());
|
|
189
|
-
const res = await get(app, DESKTOP_UA, '/abc123?x=' + 'a'.repeat(600));
|
|
190
|
-
expect(res.body).toContain('&url=http%3A%2F%2Fgo.example%2Fabc123"');
|
|
191
|
-
});
|
|
192
|
-
});
|
|
193
|
-
describe('launchpad page — what does not change', () => {
|
|
194
|
-
it('leaves an iPhone visitor on the store redirect', async () => {
|
|
195
|
-
mockDb(linkRow());
|
|
196
|
-
const res = await get(app, IPHONE_UA);
|
|
197
|
-
expect(res.statusCode).toBe(302);
|
|
198
|
-
expect(res.headers.location).toBe('https://apps.apple.com/app/id1');
|
|
199
|
-
});
|
|
200
|
-
it("leaves an iPhone visitor on the store redirect even under 'always'", async () => {
|
|
201
|
-
mockDb(linkRow({ org_settings: { launchpad: { desktop: 'always' } } }));
|
|
202
|
-
const res = await get(app, IPHONE_UA);
|
|
203
|
-
expect(res.statusCode).toBe(302);
|
|
204
|
-
});
|
|
205
|
-
it('leaves an iPhone visitor on the scheme interstitial when the link has a scheme', async () => {
|
|
206
|
-
mockDb(linkRow({ app_scheme: 'demo', deep_link_path: '/p/1' }));
|
|
207
|
-
const res = await get(app, IPHONE_UA);
|
|
208
|
-
expect(res.statusCode).toBe(200);
|
|
209
|
-
expect(res.body).toContain('Opening');
|
|
210
|
-
expect(res.body).not.toContain('class="lp"');
|
|
211
|
-
});
|
|
212
|
-
it('still 404s an unknown short code', async () => {
|
|
213
|
-
mockDb(null);
|
|
214
|
-
expect((await get(app, DESKTOP_UA)).statusCode).toBe(404);
|
|
215
|
-
});
|
|
216
|
-
it('still records the click', async () => {
|
|
217
|
-
mockDb(linkRow());
|
|
218
|
-
await get(app, DESKTOP_UA);
|
|
219
|
-
await new Promise((r) => setImmediate(r));
|
|
220
|
-
expect(query.mock.calls.some(([sql]) => /INSERT INTO click_events/i.test(String(sql)))).toBe(true);
|
|
221
|
-
});
|
|
222
|
-
});
|
|
223
|
-
describe('launchpad page — content and escaping', () => {
|
|
224
|
-
it('escapes a title and an image URL carrying HTML', async () => {
|
|
225
|
-
mockDb(linkRow({ og_title: `<script>alert("xss")</script>`, og_image_url: 'https://cdn.example/a.png?x="y"' }));
|
|
226
|
-
const res = await get(app, DESKTOP_UA);
|
|
227
|
-
expect(res.body).not.toContain('<script>alert');
|
|
228
|
-
expect(res.body).toContain('<script>alert("xss")</script>');
|
|
229
|
-
expect(res.body).toContain('content="https://cdn.example/a.png?x="y""');
|
|
230
|
-
expect(res.body).toContain('src="https://cdn.example/a.png?x="y""');
|
|
231
|
-
});
|
|
232
|
-
it('applies the workspace theme', async () => {
|
|
233
|
-
mockDb(linkRow({
|
|
234
|
-
org_settings: { launchpad: { appName: 'Demo App', appIconUrl: 'https://cdn.example/icon.png', accentColor: '#0f766e' } },
|
|
235
|
-
}));
|
|
236
|
-
const res = await get(app, DESKTOP_UA);
|
|
237
|
-
expect(res.body).toContain('<span>Demo App</span>');
|
|
238
|
-
expect(res.body).toContain('<img src="https://cdn.example/icon.png" alt="" width="48" height="48">');
|
|
239
|
-
expect(res.body).toContain('--lp-accent: #0f766e;');
|
|
240
|
-
});
|
|
241
|
-
});
|
|
242
|
-
describe('launchpad page — host hooks', () => {
|
|
243
|
-
it('inserts heroHtml from resolveContent and keeps the rest of the frame', async () => {
|
|
244
|
-
await app.close();
|
|
245
|
-
app = await build({
|
|
246
|
-
launchpad: {
|
|
247
|
-
resolveContent: async (link, settings) => ({
|
|
248
|
-
title: `Resolved ${link.short_code}`,
|
|
249
|
-
description: 'from the host',
|
|
250
|
-
imageUrl: null,
|
|
251
|
-
heroHtml: `<div class="host-hero">${settings.templateId ?? 'default'}</div>`,
|
|
252
|
-
theme: {},
|
|
253
|
-
}),
|
|
254
|
-
},
|
|
255
|
-
});
|
|
256
|
-
mockDb(linkRow({ org_settings: { launchpad: { templateId: 'hero' } } }));
|
|
257
|
-
const res = await get(app, DESKTOP_UA);
|
|
258
|
-
expect(res.statusCode).toBe(200);
|
|
259
|
-
expect(res.body).toContain('<div class="host-hero">hero</div>');
|
|
260
|
-
expect(res.body).toContain('<title>Resolved abc123</title>');
|
|
261
|
-
expect(res.body).toContain('Download on the App Store');
|
|
262
|
-
});
|
|
263
|
-
it('hands resolveContent the decorated web destination, so a host hero can link its title', async () => {
|
|
264
|
-
await app.close();
|
|
265
|
-
const seen = [];
|
|
266
|
-
app = await build({
|
|
267
|
-
launchpad: {
|
|
268
|
-
resolveContent: async (_link, _settings, context) => {
|
|
269
|
-
seen.push(context);
|
|
270
|
-
return null;
|
|
271
|
-
},
|
|
272
|
-
},
|
|
273
|
-
});
|
|
274
|
-
mockDb(linkRow({
|
|
275
|
-
web_fallback_url: 'https://example.com/e',
|
|
276
|
-
deep_link_parameters: { id: '42' },
|
|
277
|
-
org_settings: { launchpad: { desktop: 'always' } },
|
|
278
|
-
}));
|
|
279
|
-
const res = await get(app, DESKTOP_UA);
|
|
280
|
-
expect(res.statusCode).toBe(200);
|
|
281
|
-
expect(seen).toHaveLength(1);
|
|
282
|
-
const { webUrl } = seen[0];
|
|
283
|
-
expect(new URL(webUrl).origin + new URL(webUrl).pathname).toBe('https://example.com/e');
|
|
284
|
-
expect(new URL(webUrl).searchParams.get('id')).toBe('42');
|
|
285
|
-
});
|
|
286
|
-
it('links the header to the workspace app website from the settings', async () => {
|
|
287
|
-
mockDb(linkRow({
|
|
288
|
-
org_settings: { launchpad: { appName: 'Demo App', appWebsiteUrl: 'https://demo.example' } },
|
|
289
|
-
}));
|
|
290
|
-
const res = await get(app, DESKTOP_UA);
|
|
291
|
-
expect(res.statusCode).toBe(200);
|
|
292
|
-
expect(res.body).toContain('<a class="lp-app-link" data-lp-cta="cta_app_website" href="https://demo.example">');
|
|
293
|
-
});
|
|
294
|
-
it('falls back to the default content when resolveContent throws or returns null', async () => {
|
|
295
|
-
await app.close();
|
|
296
|
-
let calls = 0;
|
|
297
|
-
app = await build({
|
|
298
|
-
launchpad: {
|
|
299
|
-
resolveContent: async () => {
|
|
300
|
-
calls++;
|
|
301
|
-
if (calls === 1)
|
|
302
|
-
throw new Error('boom');
|
|
303
|
-
return null;
|
|
304
|
-
},
|
|
305
|
-
},
|
|
306
|
-
});
|
|
307
|
-
mockDb(linkRow({ og_title: 'Default title' }));
|
|
308
|
-
const thrown = await get(app, DESKTOP_UA);
|
|
309
|
-
expect(thrown.statusCode).toBe(200);
|
|
310
|
-
expect(thrown.body).toContain('<h1>Default title</h1>');
|
|
311
|
-
const nulled = await get(app, DESKTOP_UA);
|
|
312
|
-
expect(nulled.statusCode).toBe(200);
|
|
313
|
-
expect(nulled.body).toContain('<h1>Default title</h1>');
|
|
314
|
-
expect(calls).toBe(2);
|
|
315
|
-
});
|
|
316
|
-
it('emits beacons only when a beacon URL is configured, and admits its origin in the CSP', async () => {
|
|
317
|
-
mockDb(linkRow());
|
|
318
|
-
const silent = await get(app, DESKTOP_UA);
|
|
319
|
-
expect(silent.body).not.toContain('sendBeacon');
|
|
320
|
-
await app.close();
|
|
321
|
-
app = await build({ launchpad: { beaconUrl: 'https://events.example/v1/launchpad' } });
|
|
322
|
-
mockDb(linkRow());
|
|
323
|
-
const res = await get(app, DESKTOP_UA);
|
|
324
|
-
expect(res.body.match(/navigator\.sendBeacon\(/g)).toHaveLength(1);
|
|
325
|
-
expect(res.body).toContain('data-lp-cta="cta_ios"');
|
|
326
|
-
expect(res.body).toContain('data-lp-cta="cta_android"');
|
|
327
|
-
expect(res.headers['content-security-policy']).toContain("connect-src 'self' https://events.example");
|
|
328
|
-
});
|
|
329
|
-
});
|
|
330
|
-
describe('launchpad page — mobile mode', () => {
|
|
331
|
-
const pageMode = { launchpad: { mobile: 'page' } };
|
|
332
|
-
it("serves the page to an iPhone with a scheme: 'Open in app' is a button, nothing navigates", async () => {
|
|
333
|
-
mockDb(linkRow({ app_scheme: 'demo', deep_link_path: '/p/1', deep_link_parameters: { x: '1' }, org_settings: pageMode }));
|
|
334
|
-
const res = await get(app, IPHONE_UA);
|
|
335
|
-
expect(res.statusCode).toBe(200);
|
|
336
|
-
expect(res.headers['content-security-policy']).toMatch(/script-src 'nonce-/);
|
|
337
|
-
expect(res.body).toContain('class="lp"');
|
|
338
|
-
expect(res.body).toContain('id="lp-open"');
|
|
339
|
-
expect(res.body).toContain('data-scheme="demo://p/1?x=1"');
|
|
340
|
-
expect(res.body).toContain('href="demo://p/1?x=1"');
|
|
341
|
-
expect(res.body).toContain('window.location.hash');
|
|
342
|
-
expect(res.body).not.toContain('setTimeout');
|
|
343
|
-
expect(res.body).not.toContain('location.replace');
|
|
344
|
-
expect(res.body).not.toContain('Opening');
|
|
345
|
-
});
|
|
346
|
-
it('shows only the visitor\'s own store, and no QR block', async () => {
|
|
347
|
-
mockDb(linkRow({ org_settings: pageMode }));
|
|
348
|
-
const iphone = await get(app, IPHONE_UA);
|
|
349
|
-
expect(iphone.body).toContain('Download on the App Store');
|
|
350
|
-
expect(iphone.body).not.toContain('Get it on Google Play');
|
|
351
|
-
expect(iphone.body).not.toContain('class="lp-qr"');
|
|
352
|
-
mockDb(linkRow({ org_settings: pageMode }));
|
|
353
|
-
const android = await get(app, ANDROID_UA);
|
|
354
|
-
expect(android.body).toContain('Get it on Google Play');
|
|
355
|
-
expect(android.body).not.toContain('Download on the App Store');
|
|
356
|
-
});
|
|
357
|
-
it('serves the page without an Open button when the link has no scheme', async () => {
|
|
358
|
-
mockDb(linkRow({ org_settings: pageMode }));
|
|
359
|
-
const res = await get(app, IPHONE_UA);
|
|
360
|
-
expect(res.statusCode).toBe(200);
|
|
361
|
-
expect(res.body).toContain('class="lp"');
|
|
362
|
-
expect(res.body).not.toContain('id="lp-open"');
|
|
363
|
-
expect(res.body).not.toContain('<script');
|
|
364
|
-
});
|
|
365
|
-
it('keeps the 302 to a Universal Link / App Link — the OS owns the installed case', async () => {
|
|
366
|
-
mockDb(linkRow({ ios_universal_link: 'https://app.example/p/1', org_settings: pageMode }));
|
|
367
|
-
const ios = await get(app, IPHONE_UA);
|
|
368
|
-
expect(ios.statusCode).toBe(302);
|
|
369
|
-
expect(ios.headers.location).toBe('https://app.example/p/1');
|
|
370
|
-
mockDb(linkRow({ android_app_link: 'https://app.example/p/1', org_settings: pageMode }));
|
|
371
|
-
const android = await get(app, ANDROID_UA);
|
|
372
|
-
expect(android.statusCode).toBe(302);
|
|
373
|
-
});
|
|
374
|
-
it('a Universal Link for iOS does not stop the page on Android', async () => {
|
|
375
|
-
mockDb(linkRow({ ios_universal_link: 'https://app.example/p/1', org_settings: pageMode }));
|
|
376
|
-
const android = await get(app, ANDROID_UA);
|
|
377
|
-
expect(android.statusCode).toBe(200);
|
|
378
|
-
expect(android.body).toContain('class="lp"');
|
|
379
|
-
});
|
|
380
|
-
it('serves the page inside an in-app browser, where a bare scheme redirect fails silently', async () => {
|
|
381
|
-
mockDb(linkRow({ app_scheme: 'demo', deep_link_path: '/p/1', web_fallback_url: 'https://example.com/p/1', org_settings: pageMode }));
|
|
382
|
-
const res = await get(app, FB_IOS_UA);
|
|
383
|
-
expect(res.statusCode).toBe(200);
|
|
384
|
-
expect(res.body).toContain('id="lp-open"');
|
|
385
|
-
});
|
|
386
|
-
/**
|
|
387
|
-
* In-app browsers bypass Universal Links, so the store redirect that a regular
|
|
388
|
-
* browser gets would send someone who has the app to the store. The redirect
|
|
389
|
-
* path handles that by preferring the web fallback there (pickMobileFallbackUrl),
|
|
390
|
-
* which gives the OS a second chance to open the app on the next hop. In page
|
|
391
|
-
* mode that hop must still exist — as a link.
|
|
392
|
-
*/
|
|
393
|
-
it('keeps the web fallback reachable from the page, so the Universal Link second chance survives', async () => {
|
|
394
|
-
mockDb(linkRow({ web_fallback_url: 'https://app.example/p/1', org_settings: pageMode }));
|
|
395
|
-
const inApp = await get(app, FB_IOS_UA);
|
|
396
|
-
expect(inApp.statusCode).toBe(200);
|
|
397
|
-
expect(inApp.body).toContain('href="https://app.example/p/1">Continue on the web</a>');
|
|
398
|
-
expect(inApp.body).toContain('Download on the App Store');
|
|
399
|
-
// Store mode is untouched either way: regular browser → store, in-app browser → web fallback.
|
|
400
|
-
mockDb(linkRow({ web_fallback_url: 'https://app.example/p/1' }));
|
|
401
|
-
const safari = await get(app, IPHONE_UA);
|
|
402
|
-
expect(safari.statusCode).toBe(302);
|
|
403
|
-
expect(safari.headers.location).toBe('https://apps.apple.com/app/id1');
|
|
404
|
-
mockDb(linkRow({ web_fallback_url: 'https://app.example/p/1' }));
|
|
405
|
-
const fb = await get(app, FB_IOS_UA);
|
|
406
|
-
expect(fb.statusCode).toBe(302);
|
|
407
|
-
expect(fb.headers.location).toBe('https://app.example/p/1');
|
|
408
|
-
});
|
|
409
|
-
it("a link set to 'off' keeps the store behaviour under 'page'", async () => {
|
|
410
|
-
mockDb(linkRow({ launchpad_mode: 'off', org_settings: pageMode }));
|
|
411
|
-
const res = await get(app, IPHONE_UA);
|
|
412
|
-
expect(res.statusCode).toBe(302);
|
|
413
|
-
expect(res.headers.location).toBe('https://apps.apple.com/app/id1');
|
|
414
|
-
});
|
|
415
|
-
it("a link set to 'on' does not force the page onto a workspace that chose 'store'", async () => {
|
|
416
|
-
mockDb(linkRow({ launchpad_mode: 'on' }));
|
|
417
|
-
const res = await get(app, IPHONE_UA);
|
|
418
|
-
expect(res.statusCode).toBe(302);
|
|
419
|
-
});
|
|
420
|
-
it("'store' and an absent setting produce the same interstitial, byte for byte", async () => {
|
|
421
|
-
const row = { app_scheme: 'demo', deep_link_path: '/p/1', deep_link_parameters: { x: '1', y: 'two words' } };
|
|
422
|
-
mockDb(linkRow(row));
|
|
423
|
-
const absent = await get(app, IPHONE_UA);
|
|
424
|
-
mockDb(linkRow({ ...row, org_settings: { launchpad: { mobile: 'store' } } }));
|
|
425
|
-
const explicit = await get(app, IPHONE_UA);
|
|
426
|
-
expect(absent.statusCode).toBe(200);
|
|
427
|
-
expect(absent.body).toContain('Opening');
|
|
428
|
-
expect(absent.body).toContain('setTimeout');
|
|
429
|
-
expect(absent.body).toContain('id="open-btn" href="demo://p/1?x=1&y=two+words"');
|
|
430
|
-
expect(absent.body).not.toContain('class="lp"');
|
|
431
|
-
expect(explicit.body).toBe(absent.body);
|
|
432
|
-
expect(explicit.headers['content-type']).toBe(absent.headers['content-type']);
|
|
433
|
-
});
|
|
434
|
-
it('page mode changes nothing on desktop', async () => {
|
|
435
|
-
mockDb(linkRow({ web_fallback_url: 'https://example.com/page', org_settings: pageMode }));
|
|
436
|
-
const res = await get(app, DESKTOP_UA);
|
|
437
|
-
expect(res.statusCode).toBe(302);
|
|
438
|
-
});
|
|
439
|
-
});
|
|
440
|
-
describe('launchpad page — instance defaults (linkforty.config.json)', () => {
|
|
441
|
-
/** The page carries a fresh nonce per response; everything else must match. */
|
|
442
|
-
const withoutNonce = (html) => html.replace(/nonce-[A-Za-z0-9+/=]+|nonce="[A-Za-z0-9+/=]+"/g, 'nonce');
|
|
443
|
-
it('changes nothing when there are no defaults', async () => {
|
|
444
|
-
const rows = [
|
|
445
|
-
linkRow({ og_title: 'Example App' }),
|
|
446
|
-
linkRow({ org_settings: { launchpad: { appName: 'Demo App', accentColor: '#0f766e', desktop: 'always' } }, web_fallback_url: 'https://example.com/page' }),
|
|
447
|
-
];
|
|
448
|
-
const withEmpty = await build({ launchpad: { defaults: {} } });
|
|
449
|
-
try {
|
|
450
|
-
for (const row of rows) {
|
|
451
|
-
for (const ua of [DESKTOP_UA, IPHONE_UA]) {
|
|
452
|
-
mockDb(row);
|
|
453
|
-
const before = await get(app, ua);
|
|
454
|
-
mockDb(row);
|
|
455
|
-
const after = await get(withEmpty, ua);
|
|
456
|
-
expect(after.statusCode).toBe(before.statusCode);
|
|
457
|
-
expect(after.headers.location).toBe(before.headers.location);
|
|
458
|
-
expect(withoutNonce(after.body)).toBe(withoutNonce(before.body));
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
finally {
|
|
463
|
-
await new Promise((r) => setImmediate(r));
|
|
464
|
-
await withEmpty.close();
|
|
465
|
-
}
|
|
466
|
-
});
|
|
467
|
-
it("serves the page in front of a destination when the instance chose 'always'", async () => {
|
|
468
|
-
await app.close();
|
|
469
|
-
app = await build({ launchpad: { defaults: { desktop: 'always' } } });
|
|
470
|
-
mockDb(linkRow({ web_fallback_url: 'https://example.com/page' }));
|
|
471
|
-
const res = await get(app, DESKTOP_UA);
|
|
472
|
-
expect(res.statusCode).toBe(200);
|
|
473
|
-
expect(res.body).toContain('class="lp"');
|
|
474
|
-
});
|
|
475
|
-
it('themes a link that has no organization', async () => {
|
|
476
|
-
await app.close();
|
|
477
|
-
app = await build({ launchpad: { defaults: { appName: 'Instance App', accentColor: '#123456' } } });
|
|
478
|
-
mockDb(linkRow());
|
|
479
|
-
const res = await get(app, DESKTOP_UA);
|
|
480
|
-
expect(res.body).toContain('<span>Instance App</span>');
|
|
481
|
-
expect(res.body).toContain('--lp-accent: #123456;');
|
|
482
|
-
});
|
|
483
|
-
it("lets the organization override per field, keeping the instance's other fields", async () => {
|
|
484
|
-
await app.close();
|
|
485
|
-
app = await build({ launchpad: { defaults: { appName: 'Instance App', accentColor: '#123456' } } });
|
|
486
|
-
mockDb(linkRow({ org_settings: { launchpad: { appName: 'Org App' } } }));
|
|
487
|
-
const res = await get(app, DESKTOP_UA);
|
|
488
|
-
expect(res.body).toContain('<span>Org App</span>');
|
|
489
|
-
expect(res.body).not.toContain('Instance App');
|
|
490
|
-
expect(res.body).toContain('--lp-accent: #123456;');
|
|
491
|
-
});
|
|
492
|
-
it("serves the page to an iPhone when the instance chose mobile 'page'", async () => {
|
|
493
|
-
await app.close();
|
|
494
|
-
app = await build({ launchpad: { defaults: { mobile: 'page' } } });
|
|
495
|
-
mockDb(linkRow());
|
|
496
|
-
const res = await get(app, IPHONE_UA);
|
|
497
|
-
expect(res.statusCode).toBe(200);
|
|
498
|
-
expect(res.body).toContain('class="lp"');
|
|
499
|
-
});
|
|
500
|
-
it("a link set to 'off' still wins over an instance set to 'always'", async () => {
|
|
501
|
-
await app.close();
|
|
502
|
-
app = await build({ launchpad: { defaults: { desktop: 'always' } } });
|
|
503
|
-
mockDb(linkRow({ web_fallback_url: 'https://example.com/page', launchpad_mode: 'off' }));
|
|
504
|
-
const res = await get(app, DESKTOP_UA);
|
|
505
|
-
expect(res.statusCode).toBe(302);
|
|
506
|
-
});
|
|
507
|
-
it("an organization set to 'off' wins over an instance set to 'always'", async () => {
|
|
508
|
-
await app.close();
|
|
509
|
-
app = await build({ launchpad: { defaults: { desktop: 'always' } } });
|
|
510
|
-
mockDb(linkRow({ web_fallback_url: 'https://example.com/page', org_settings: { launchpad: { desktop: 'off' } } }));
|
|
511
|
-
const res = await get(app, DESKTOP_UA);
|
|
512
|
-
expect(res.statusCode).toBe(302);
|
|
513
|
-
});
|
|
514
|
-
});
|
|
515
|
-
//# sourceMappingURL=redirect.launchpad.test.js.map
|