Browse Source

fix(webviewShared.js): Support all valid URI schemes and add testing (#293)

* Change origin whitelist to allow for all valid URIs

- Now supports +, -, and .
- Prevent whitelist from matching when preceded by unwanted characters
- URI must begin with letter
- URI Scheme syntax: https://tools.ietf.org/html/rfc3986#section-3.1

* Add jest testing framework and run it on CI

* Add tests for WebViewShared's createOnShouldStartLoadWithRequest
Malcolm Scruggs 5 years ago
parent
commit
fb78d13120

+ 12
- 0
babel.config.js View File

@@ -0,0 +1,12 @@
1
+module.exports = function (api) {
2
+    api && api.cache(false);
3
+    return {
4
+      env: {
5
+        test: {
6
+          presets: [
7
+            "module:metro-react-native-babel-preset"
8
+          ],
9
+        }
10
+      }
11
+    };
12
+  }

+ 1
- 0
docs/Contributing.md View File

@@ -49,6 +49,7 @@ $ yarn add ../react-native-webview && react-native link react-native-webview
49 49
 - After pulling this repo and installing all dependencies, you can run flow on iOS and Android-specific files using the commands:
50 50
   - `yarn test:ios:flow` for iOS
51 51
   - `yarn test:android:flow` for Android
52
+- You can run Jest tests using the command: `yarn test:js`
52 53
 - If you want to add another React Native platform to this repository, you will need to create another `.flowconfig` for it. If your platform is `example`, copy the main flowconfig and rename it to `.flowconfig.example`. Then edit the config to ignore other platforms, and add `.*/*[.]example.js` to the ignore lists of the other platforms. Then add an entry to `package.json` like this:
53 54
   - `"test:example:flow": "flow check --flowconfig-name .flowconfig.example"`
54 55
 - Currently you need to install React Native 0.57 to be able to test these types - `flow check` will not pass against 0.56.

+ 185
- 0
jest.config.js View File

@@ -0,0 +1,185 @@
1
+// For a detailed explanation regarding each configuration property, visit:
2
+// https://jestjs.io/docs/en/configuration.html
3
+
4
+module.exports = {
5
+  // All imported modules in your tests should be mocked automatically
6
+  // automock: false,
7
+
8
+  // Stop running tests after `n` failures
9
+  // bail: 0,
10
+
11
+  // Respect "browser" field in package.json when resolving modules
12
+  // browser: false,
13
+
14
+  // The directory where Jest should store its cached dependency information
15
+  // cacheDirectory: "/private/var/folders/8f/kgcy219d1dvfvbcqky441_d00000gp/T/jest_dy",
16
+
17
+  // Automatically clear mock calls and instances between every test
18
+  clearMocks: true,
19
+
20
+  // Indicates whether the coverage information should be collected while executing the test
21
+  // collectCoverage: false,
22
+
23
+  // An array of glob patterns indicating a set of files for which coverage information should be collected
24
+  // collectCoverageFrom: null,
25
+
26
+  // The directory where Jest should output its coverage files
27
+  // coverageDirectory: "coverage",
28
+
29
+  // An array of regexp pattern strings used to skip coverage collection
30
+  // coveragePathIgnorePatterns: [
31
+  //   "/node_modules/"
32
+  // ],
33
+
34
+  // A list of reporter names that Jest uses when writing coverage reports
35
+  // coverageReporters: [
36
+  //   "json",
37
+  //   "text",
38
+  //   "lcov",
39
+  //   "clover"
40
+  // ],
41
+
42
+  // An object that configures minimum threshold enforcement for coverage results
43
+  // coverageThreshold: null,
44
+
45
+  // A path to a custom dependency extractor
46
+  // dependencyExtractor: null,
47
+
48
+  // Make calling deprecated APIs throw helpful error messages
49
+  // errorOnDeprecated: false,
50
+
51
+  // Force coverage collection from ignored files usin a array of glob patterns
52
+  // forceCoverageMatch: [],
53
+
54
+  // A path to a module which exports an async function that is triggered once before all test suites
55
+  // globalSetup: null,
56
+
57
+  // A path to a module which exports an async function that is triggered once after all test suites
58
+  // globalTeardown: null,
59
+
60
+  // A set of global variables that need to be available in all test environments
61
+  // globals: {},
62
+
63
+  // An array of directory names to be searched recursively up from the requiring module's location
64
+  // moduleDirectories: [
65
+  //   "node_modules"
66
+  // ],
67
+
68
+  // An array of file extensions your modules use
69
+  // moduleFileExtensions: [
70
+  //   "js",
71
+  //   "json",
72
+  //   "jsx",
73
+  //   "ts",
74
+  //   "tsx",
75
+  //   "node"
76
+  // ],
77
+
78
+  // A map from regular expressions to module names that allow to stub out resources with a single module
79
+  // moduleNameMapper: {},
80
+
81
+  // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
82
+  // modulePathIgnorePatterns: [],
83
+
84
+  // Activates notifications for test results
85
+  // notify: false,
86
+
87
+  // An enum that specifies notification mode. Requires { notify: true }
88
+  // notifyMode: "failure-change",
89
+
90
+  // A preset that is used as a base for Jest's configuration
91
+  preset: "react-native",
92
+
93
+  // Run tests from one or more projects
94
+  // projects: null,
95
+
96
+  // Use this configuration option to add custom reporters to Jest
97
+  // reporters: undefined,
98
+
99
+  // Automatically reset mock state between every test
100
+  // resetMocks: false,
101
+
102
+  // Reset the module registry before running each individual test
103
+  // resetModules: false,
104
+
105
+  // A path to a custom resolver
106
+  // resolver: null,
107
+
108
+  // Automatically restore mock state between every test
109
+  // restoreMocks: false,
110
+
111
+  // The root directory that Jest should scan for tests and modules within
112
+  // rootDir: null,
113
+
114
+  // A list of paths to directories that Jest should use to search for files in
115
+  // roots: [
116
+  //   "<rootDir>"
117
+  // ],
118
+
119
+  // Allows you to use a custom runner instead of Jest's default test runner
120
+  // runner: "jest-runner",
121
+
122
+  // The paths to modules that run some code to configure or set up the testing environment before each test
123
+  // setupFiles: [],
124
+
125
+  // A list of paths to modules that run some code to configure or set up the testing framework before each test
126
+  // setupFilesAfterEnv: [],
127
+
128
+  // A list of paths to snapshot serializer modules Jest should use for snapshot testing
129
+  // snapshotSerializers: [],
130
+
131
+  // The test environment that will be used for testing
132
+  testEnvironment: "node",
133
+
134
+  // Options that will be passed to the testEnvironment
135
+  // testEnvironmentOptions: {},
136
+
137
+  // Adds a location field to test results
138
+  // testLocationInResults: false,
139
+
140
+  // The glob patterns Jest uses to detect test files
141
+  // testMatch: [
142
+  //   "**/__tests__/**/*.[jt]s?(x)",
143
+  //   "**/?(*.)+(spec|test).[tj]s?(x)"
144
+  // ],
145
+
146
+  // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
147
+  // testPathIgnorePatterns: [
148
+  //   "/node_modules/"
149
+  // ],
150
+
151
+  // The regexp pattern or array of patterns that Jest uses to detect test files
152
+  // testRegex: [],
153
+
154
+  // This option allows the use of a custom results processor
155
+  // testResultsProcessor: null,
156
+
157
+  // This option allows use of a custom test runner
158
+  // testRunner: "jasmine2",
159
+
160
+  // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
161
+  // testURL: "http://localhost",
162
+
163
+  // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
164
+  // timers: "real",
165
+
166
+  // A map from regular expressions to paths to transformers
167
+  // transform: null,
168
+
169
+  // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
170
+  // transformIgnorePatterns: [
171
+  //   "/node_modules/"
172
+  // ],
173
+
174
+  // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
175
+  // unmockedModulePathPatterns: undefined,
176
+
177
+  // Indicates whether each individual test should be reported during the run
178
+  // verbose: null,
179
+
180
+  // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
181
+  // watchPathIgnorePatterns: [],
182
+
183
+  // Whether to use watchman for file crawling
184
+  // watchman: true,
185
+};

+ 2
- 2
js/WebViewShared.js View File

@@ -19,12 +19,12 @@ import type {
19 19
 const defaultOriginWhitelist = ['http://*', 'https://*'];
20 20
 
21 21
 const extractOrigin = (url: string): string => {
22
-  const result = /^[A-Za-z0-9]+:(\/\/)?[^/]*/.exec(url);
22
+  const result = /^[A-Za-z][A-Za-z0-9\+\-\.]+:(\/\/)?[^/]*/.exec(url);
23 23
   return result === null ? '' : result[0];
24 24
 };
25 25
 
26 26
 const originWhitelistToRegex = (originWhitelist: string): string =>
27
-  escapeStringRegexp(originWhitelist).replace(/\\\*/g, '.*');
27
+    `^${escapeStringRegexp(originWhitelist).replace(/\\\*/g, '.*')}`;
28 28
 
29 29
 const passesWhitelist = (compiledWhitelist: Array<string>, url: string) => {
30 30
   const origin = extractOrigin(url);

+ 136
- 0
js/__tests__/WebViewShared-test.js View File

@@ -0,0 +1,136 @@
1
+import { Linking } from 'react-native';
2
+
3
+import {
4
+  defaultOriginWhitelist,
5
+  createOnShouldStartLoadWithRequest,
6
+} from '../WebViewShared';
7
+
8
+describe('WebViewShared', () => {
9
+  test('exports defaultOriginWhitelist', () => {
10
+    expect(defaultOriginWhitelist).toMatchSnapshot();
11
+  });
12
+
13
+  describe('createOnShouldStartLoadWithRequest', () => {
14
+    const alwaysTrueOnShouldStartLoadWithRequest = (nativeEvent) => {
15
+      return true;
16
+    };
17
+
18
+    const alwaysFalseOnShouldStartLoadWithRequest = (nativeEvent) => {
19
+      return false;
20
+    };
21
+
22
+    const loadRequest = jest.fn();
23
+
24
+    test('loadRequest is called without onShouldStartLoadWithRequest override', () => {
25
+      const onShouldStartLoadWithRequest = createOnShouldStartLoadWithRequest(
26
+        loadRequest,
27
+        defaultOriginWhitelist,
28
+      );
29
+
30
+      onShouldStartLoadWithRequest({ nativeEvent: { url: 'https://www.example.com/', lockIdentifier: 1 } });
31
+      expect(Linking.openURL).toHaveBeenCalledTimes(0);
32
+      expect(loadRequest).toHaveBeenCalledWith(true, 'https://www.example.com/', 1);
33
+    });
34
+
35
+    test('Linking.openURL is called without onShouldStartLoadWithRequest override', () => {
36
+      const onShouldStartLoadWithRequest = createOnShouldStartLoadWithRequest(
37
+        loadRequest,
38
+        defaultOriginWhitelist,
39
+      );
40
+
41
+      onShouldStartLoadWithRequest({ nativeEvent: { url: 'invalid://example.com/', lockIdentifier: 2 } });
42
+      expect(Linking.openURL).toHaveBeenCalledWith('invalid://example.com/');
43
+      expect(loadRequest).toHaveBeenCalledWith(false, 'invalid://example.com/', 2);
44
+    });
45
+
46
+    test('loadRequest with true onShouldStartLoadWithRequest override is called', () => {
47
+      const onShouldStartLoadWithRequest = createOnShouldStartLoadWithRequest(
48
+        loadRequest,
49
+        defaultOriginWhitelist,
50
+        alwaysTrueOnShouldStartLoadWithRequest,
51
+      );
52
+
53
+      onShouldStartLoadWithRequest({ nativeEvent: { url: 'https://www.example.com/', lockIdentifier: 1 } });
54
+      expect(Linking.openURL).toHaveBeenCalledTimes(0);
55
+      expect(loadRequest).toHaveBeenLastCalledWith(true, 'https://www.example.com/', 1);
56
+    });
57
+
58
+    test('Linking.openURL with true onShouldStartLoadWithRequest override is called for links not passing the whitelist', () => {
59
+      const onShouldStartLoadWithRequest = createOnShouldStartLoadWithRequest(
60
+        loadRequest,
61
+        defaultOriginWhitelist,
62
+        alwaysTrueOnShouldStartLoadWithRequest,
63
+      );
64
+
65
+      onShouldStartLoadWithRequest({ nativeEvent: { url: 'invalid://example.com/', lockIdentifier: 1 } });
66
+      expect(Linking.openURL).toHaveBeenLastCalledWith('invalid://example.com/');
67
+      expect(loadRequest).toHaveBeenLastCalledWith(true, 'invalid://example.com/', 1);
68
+    });
69
+
70
+    test('loadRequest with false onShouldStartLoadWithRequest override is called', () => {
71
+      const onShouldStartLoadWithRequest = createOnShouldStartLoadWithRequest(
72
+        loadRequest,
73
+        defaultOriginWhitelist,
74
+        alwaysFalseOnShouldStartLoadWithRequest,
75
+      );
76
+
77
+      onShouldStartLoadWithRequest({ nativeEvent: { url: 'https://www.example.com/', lockIdentifier: 1 } });
78
+      expect(Linking.openURL).toHaveBeenCalledTimes(0);
79
+      expect(loadRequest).toHaveBeenLastCalledWith(false, 'https://www.example.com/', 1);
80
+    });
81
+
82
+    test('loadRequest with limited whitelist', () => {
83
+      const onShouldStartLoadWithRequest = createOnShouldStartLoadWithRequest(
84
+        loadRequest,
85
+        ['https://*'],
86
+      );
87
+
88
+      onShouldStartLoadWithRequest({ nativeEvent: { url: 'https://www.example.com/', lockIdentifier: 1 } });
89
+      expect(Linking.openURL).toHaveBeenCalledTimes(0);
90
+      expect(loadRequest).toHaveBeenLastCalledWith(true, 'https://www.example.com/', 1);
91
+
92
+      onShouldStartLoadWithRequest({ nativeEvent: { url: 'http://insecure.com/', lockIdentifier: 2 } });
93
+      expect(Linking.openURL).toHaveBeenLastCalledWith('http://insecure.com/');
94
+      expect(loadRequest).toHaveBeenLastCalledWith(false, 'http://insecure.com/', 2);
95
+
96
+      onShouldStartLoadWithRequest({ nativeEvent: { url: 'git+https://insecure.com/', lockIdentifier: 3 } });
97
+      expect(Linking.openURL).toHaveBeenLastCalledWith('git+https://insecure.com/');
98
+      expect(loadRequest).toHaveBeenLastCalledWith(false, 'git+https://insecure.com/', 3);
99
+
100
+      onShouldStartLoadWithRequest({ nativeEvent: { url: 'fakehttps://insecure.com/', lockIdentifier: 4 } });
101
+      expect(Linking.openURL).toHaveBeenLastCalledWith('fakehttps://insecure.com/');
102
+      expect(loadRequest).toHaveBeenLastCalledWith(false, 'fakehttps://insecure.com/', 4);
103
+    });
104
+
105
+    test('loadRequest allows for valid URIs', () => {
106
+      const onShouldStartLoadWithRequest = createOnShouldStartLoadWithRequest(
107
+          loadRequest,
108
+          ['plus+https://*', 'DOT.https://*', 'dash-https://*', '0invalid://*', '+invalid://*'],
109
+      );
110
+
111
+      onShouldStartLoadWithRequest({ nativeEvent: { url: 'plus+https://www.example.com/', lockIdentifier: 1 } });
112
+      expect(Linking.openURL).toHaveBeenCalledTimes(0);
113
+      expect(loadRequest).toHaveBeenLastCalledWith(true, 'plus+https://www.example.com/', 1);
114
+
115
+      onShouldStartLoadWithRequest({ nativeEvent: { url: 'DOT.https://www.example.com/', lockIdentifier: 2 } });
116
+      expect(Linking.openURL).toHaveBeenCalledTimes(0);
117
+      expect(loadRequest).toHaveBeenLastCalledWith(true, 'DOT.https://www.example.com/', 2);
118
+
119
+      onShouldStartLoadWithRequest({ nativeEvent: { url: 'dash-https://www.example.com/', lockIdentifier: 3 } });
120
+      expect(Linking.openURL).toHaveBeenCalledTimes(0);
121
+      expect(loadRequest).toHaveBeenLastCalledWith(true, 'dash-https://www.example.com/', 3);
122
+
123
+      onShouldStartLoadWithRequest({ nativeEvent: { url: '0invalid://www.example.com/', lockIdentifier: 4 } });
124
+      expect(Linking.openURL).toHaveBeenLastCalledWith('0invalid://www.example.com/');
125
+      expect(loadRequest).toHaveBeenLastCalledWith(false, '0invalid://www.example.com/', 4);
126
+
127
+      onShouldStartLoadWithRequest({ nativeEvent: { url: '+invalid://www.example.com/', lockIdentifier: 5 } });
128
+      expect(Linking.openURL).toHaveBeenLastCalledWith('+invalid://www.example.com/');
129
+      expect(loadRequest).toHaveBeenLastCalledWith(false, '+invalid://www.example.com/', 5);
130
+
131
+      onShouldStartLoadWithRequest({ nativeEvent: { url: 'FAKE+plus+https://www.example.com/', lockIdentifier: 6 } });
132
+      expect(Linking.openURL).toHaveBeenLastCalledWith('FAKE+plus+https://www.example.com/');
133
+      expect(loadRequest).toHaveBeenLastCalledWith(false, 'FAKE+plus+https://www.example.com/', 6);
134
+    });
135
+  });
136
+});

+ 8
- 0
js/__tests__/__snapshots__/WebViewShared-test.js.snap View File

@@ -0,0 +1,8 @@
1
+// Jest Snapshot v1, https://goo.gl/fbAQLP
2
+
3
+exports[`WebViewShared exports defaultOriginWhitelist 1`] = `
4
+Array [
5
+  "http://*",
6
+  "https://*",
7
+]
8
+`;

+ 7
- 1
package.json View File

@@ -11,11 +11,13 @@
11 11
   "version": "5.0.1",
12 12
   "homepage": "https://github.com/react-native-community/react-native-webview#readme",
13 13
   "scripts": {
14
+    "test:js": "jest",
14 15
     "test:ios:flow": "flow check",
15 16
     "test:android:flow": "flow check --flowconfig-name .flowconfig.android",
16 17
     "ci:publish": "yarn semantic-release",
17
-    "ci:test": "yarn ci:test:flow",
18
+    "ci:test": "yarn ci:test:flow && yarn ci:test:js",
18 19
     "ci:test:flow": "yarn test:ios:flow && yarn test:android:flow",
20
+    "ci:test:js": "yarn test:js",
19 21
     "semantic-release": "semantic-release"
20 22
   },
21 23
   "peerDependencies": {
@@ -27,10 +29,14 @@
27 29
     "fbjs": "^0.8.17"
28 30
   },
29 31
   "devDependencies": {
32
+    "@babel/core": "^7.2.2",
30 33
     "@semantic-release/git": "7.0.5",
31 34
     "@types/react": "^16.4.18",
32 35
     "@types/react-native": "^0.57.6",
36
+    "babel-jest": "^24.0.0",
33 37
     "flow-bin": "^0.80.0",
38
+    "jest": "^24.0.0",
39
+    "metro-react-native-babel-preset": "^0.51.1",
34 40
     "react-native": "^0.57",
35 41
     "semantic-release": "15.10.3"
36 42
   },

+ 1287
- 13
yarn.lock
File diff suppressed because it is too large
View File