1
|
# SPDX-License-Identifier: CC0-1.0
|
2
|
|
3
|
"""
|
4
|
Haketilo unit tests - building pattern tree and putting it in a content script
|
5
|
"""
|
6
|
|
7
|
# This file is part of Haketilo
|
8
|
#
|
9
|
# Copyright (C) 2021,2022 Wojtek Kosior <koszko@koszko.org>
|
10
|
#
|
11
|
# This program is free software: you can redistribute it and/or modify
|
12
|
# it under the terms of the CC0 1.0 Universal License as published by
|
13
|
# the Creative Commons Corporation.
|
14
|
#
|
15
|
# This program is distributed in the hope that it will be useful,
|
16
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
17
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
18
|
# CC0 1.0 Universal License for more details.
|
19
|
|
20
|
import pytest
|
21
|
import json
|
22
|
from selenium.webdriver.support.ui import WebDriverWait
|
23
|
|
24
|
from ..script_loader import load_script
|
25
|
|
26
|
def simple_sample_mapping(patterns, fruit):
|
27
|
if type(patterns) is not list:
|
28
|
patterns = [patterns]
|
29
|
payloads = dict([(p, {'identifier': f'{fruit}-{p}'}) for p in patterns])
|
30
|
return {
|
31
|
'source_copyright': [],
|
32
|
'type': 'mapping',
|
33
|
'identifier': f'inject-{fruit}',
|
34
|
'payloads': payloads
|
35
|
}
|
36
|
|
37
|
def get_content_script_values(driver, content_script):
|
38
|
"""
|
39
|
Allow easy extraction of 'this.something = ...' values from generated
|
40
|
content script and verify the content script is syntactically correct.
|
41
|
"""
|
42
|
return driver.execute_script(
|
43
|
'''
|
44
|
function value_holder() {
|
45
|
%s;
|
46
|
return this;
|
47
|
}
|
48
|
return value_holder.call({});
|
49
|
''' % content_script)
|
50
|
|
51
|
# Fields that are not relevant for testing are omitted from these mapping
|
52
|
# definitions.
|
53
|
sample_mappings = [simple_sample_mapping(pats, fruit) for pats, fruit in [
|
54
|
(['https://gotmyowndoma.in/index.html',
|
55
|
'http://gotmyowndoma.in/index.html'], 'banana'),
|
56
|
(['https://***.gotmyowndoma.in/index.html',
|
57
|
'https://**.gotmyowndoma.in/index.html',
|
58
|
'https://*.gotmyowndoma.in/index.html',
|
59
|
'https://gotmyowndoma.in/index.html'], 'orange'),
|
60
|
('https://gotmyowndoma.in/index.html/***', 'grape'),
|
61
|
('http://gotmyowndoma.in/index.html/***', 'melon'),
|
62
|
('https://gotmyowndoma.in/index.html', 'peach'),
|
63
|
('https://gotmyowndoma.in/*', 'pear'),
|
64
|
('https://gotmyowndoma.in/**', 'raspberry'),
|
65
|
('https://gotmyowndoma.in/***', 'strawberry'),
|
66
|
('https://***.gotmyowndoma.in/index.html', 'apple'),
|
67
|
('https://***.gotmyowndoma.in/*', 'avocado'),
|
68
|
('https://***.gotmyowndoma.in/**', 'papaya'),
|
69
|
('https://***.gotmyowndoma.in/***', 'kiwi')
|
70
|
]]
|
71
|
|
72
|
sample_blocking = [f'http{s}://{dw}gotmyown%sdoma.in{i}{pw}'
|
73
|
for dw in ('', '***.', '**.', '*.')
|
74
|
for i in ('/index.html', '')
|
75
|
for pw in ('', '/', '/*')
|
76
|
for s in ('', 's')]
|
77
|
sample_blocking = [{'pattern': pattern % (i if i > 1 else ''),
|
78
|
'allow': bool(i & 1)}
|
79
|
for i, pattern in enumerate(sample_blocking)]
|
80
|
|
81
|
# Even though patterns_query_manager.js is normally meant to run from background
|
82
|
# page, some tests can be as well performed running it from a normal page.
|
83
|
@pytest.mark.get_page('https://gotmyowndoma.in')
|
84
|
def test_pqm_tree_building(driver, execute_in_page):
|
85
|
"""
|
86
|
patterns_query_manager.js tracks Haketilo's internal database and builds a
|
87
|
constantly-updated pattern tree based on its contents. Mock the database and
|
88
|
verify tree building works properly.
|
89
|
"""
|
90
|
execute_in_page(load_script('background/patterns_query_manager.js'))
|
91
|
# Mock IndexedDB and build patterns tree.
|
92
|
execute_in_page(
|
93
|
'''
|
94
|
const [initial_mappings, initial_blocking] = arguments.slice(0, 2);
|
95
|
let mappingchange, blockingchange, settingchange;
|
96
|
|
97
|
haketilodb.track.mapping = function (cb) {
|
98
|
mappingchange = cb;
|
99
|
|
100
|
return [{}, initial_mappings];
|
101
|
}
|
102
|
haketilodb.track.blocking = function (cb) {
|
103
|
blockingchange = cb;
|
104
|
|
105
|
return [{}, initial_blocking];
|
106
|
}
|
107
|
haketilodb.track.settings = function (cb) {
|
108
|
settingchange = cb;
|
109
|
|
110
|
return [{}, [{name: "default_allow", value: true}]];
|
111
|
}
|
112
|
|
113
|
let last_script;
|
114
|
let unregister_called = 0;
|
115
|
async function register_mock(injection)
|
116
|
{
|
117
|
await new Promise(resolve => setTimeout(resolve, 1));
|
118
|
last_script = injection.js[0].code;
|
119
|
return {unregister: () => unregister_called++};
|
120
|
}
|
121
|
browser = {contentScripts: {register: register_mock}};
|
122
|
|
123
|
returnval(start("abracadabra"));
|
124
|
''',
|
125
|
sample_mappings[0:2], sample_blocking[0:2])
|
126
|
|
127
|
found, tree, content_script, deregistrations = execute_in_page(
|
128
|
'''
|
129
|
returnval([pqt.search(tree, arguments[0]).next().value,
|
130
|
tree, last_script, unregister_called]);
|
131
|
''',
|
132
|
'https://gotmyowndoma.in/index.html')
|
133
|
best_pattern = 'https://gotmyowndoma.in/index.html'
|
134
|
assert found == \
|
135
|
dict([('~allow', 1),
|
136
|
*[(f'inject-{fruit}', {'identifier': f'{fruit}-{best_pattern}'})
|
137
|
for fruit in ('banana', 'orange')]])
|
138
|
cs_values = get_content_script_values(driver, content_script)
|
139
|
assert cs_values['haketilo_secret'] == 'abracadabra'
|
140
|
assert cs_values['haketilo_pattern_tree'] == tree
|
141
|
assert cs_values['haketilo_default_allow'] == True
|
142
|
assert deregistrations == 0
|
143
|
|
144
|
def condition_all_added(driver):
|
145
|
last_script = execute_in_page('returnval(last_script);')
|
146
|
cs_values = get_content_script_values(driver, last_script)
|
147
|
nums = [i for i in range(len(sample_blocking)) if i > 1]
|
148
|
return (cs_values['haketilo_default_allow'] == False and
|
149
|
all([('gotmyown%sdoma' % i) in last_script for i in nums]) and
|
150
|
all([m['identifier'] in last_script for m in sample_mappings]))
|
151
|
|
152
|
execute_in_page(
|
153
|
'''
|
154
|
const new_setting_val = {name: "default_allow", value: false};
|
155
|
settingchange({key: "default_allow", new_val: new_setting_val});
|
156
|
for (const mapping of arguments[0])
|
157
|
mappingchange({key: mapping.identifier, new_val: mapping});
|
158
|
for (const blocking of arguments[1])
|
159
|
blockingchange({key: blocking.pattern, new_val: blocking});
|
160
|
''',
|
161
|
sample_mappings[2:], sample_blocking[2:])
|
162
|
WebDriverWait(driver, 10).until(condition_all_added)
|
163
|
|
164
|
odd_mappings = \
|
165
|
[m['identifier'] for i, m in enumerate(sample_mappings) if i & 1]
|
166
|
odd_blocking = \
|
167
|
[b['pattern'] for i, b in enumerate(sample_blocking) if i & 1]
|
168
|
even_mappings = \
|
169
|
[m['identifier'] for i, m in enumerate(sample_mappings) if 1 - i & 1]
|
170
|
even_blocking = \
|
171
|
[b['pattern'] for i, b in enumerate(sample_blocking) if 1 - i & 1]
|
172
|
|
173
|
def condition_odd_removed(driver):
|
174
|
last_script = execute_in_page('returnval(last_script);')
|
175
|
nums = [i for i in range(len(sample_blocking)) if i > 1 and 1 - i & 1]
|
176
|
return (all([id not in last_script for id in odd_mappings]) and
|
177
|
all([id in last_script for id in even_mappings]) and
|
178
|
all([p not in last_script for p in odd_blocking[1:]]) and
|
179
|
all([('gotmyown%sdoma' % i) in last_script for i in nums]))
|
180
|
|
181
|
def condition_all_removed(driver):
|
182
|
content_script = execute_in_page('returnval(last_script);')
|
183
|
cs_values = get_content_script_values(driver, content_script)
|
184
|
return cs_values['haketilo_pattern_tree'] == {}
|
185
|
|
186
|
execute_in_page(
|
187
|
'''
|
188
|
arguments[0].forEach(identifier => mappingchange({key: identifier}));
|
189
|
arguments[1].forEach(pattern => blockingchange({key: pattern}));
|
190
|
''',
|
191
|
odd_mappings, odd_blocking)
|
192
|
|
193
|
WebDriverWait(driver, 10).until(condition_odd_removed)
|
194
|
|
195
|
execute_in_page(
|
196
|
'''
|
197
|
arguments[0].forEach(identifier => mappingchange({key: identifier}));
|
198
|
arguments[1].forEach(pattern => blockingchange({key: pattern}));
|
199
|
''',
|
200
|
even_mappings, even_blocking)
|
201
|
|
202
|
WebDriverWait(driver, 10).until(condition_all_removed)
|
203
|
|
204
|
content_js = '''
|
205
|
let already_run = false;
|
206
|
this.haketilo_content_script_main = function() {
|
207
|
if (already_run)
|
208
|
return;
|
209
|
already_run = true;
|
210
|
document.documentElement.innerHTML = "<body><div id='tree-json'>";
|
211
|
document.getElementById("tree-json").innerText =
|
212
|
JSON.stringify(this.haketilo_pattern_tree);
|
213
|
}
|
214
|
if (this.haketilo_pattern_tree !== undefined)
|
215
|
this.haketilo_content_script_main();
|
216
|
'''
|
217
|
|
218
|
def background_js():
|
219
|
pqm_js = load_script('background/patterns_query_manager.js',
|
220
|
"#IMPORT background/broadcast_broker.js")
|
221
|
return pqm_js + '; broadcast_broker.start(); start();'
|
222
|
|
223
|
@pytest.mark.ext_data({
|
224
|
'content_script': content_js,
|
225
|
'background_script': background_js
|
226
|
})
|
227
|
@pytest.mark.usefixtures('webextension')
|
228
|
def test_pqm_script_injection(driver, execute_in_page):
|
229
|
# Let's open a normal page in a second window. Window 0 will be used to make
|
230
|
# changed to IndexedDB and window 1 to test the working of content scripts.
|
231
|
driver.execute_script('window.open("about:blank", "_blank");')
|
232
|
windows = [*driver.window_handles]
|
233
|
assert len(windows) == 2
|
234
|
|
235
|
def run_content_script():
|
236
|
driver.switch_to.window(windows[1])
|
237
|
driver.get('https://gotmyowndoma.in/index.html')
|
238
|
windows[1] = driver.window_handles[1]
|
239
|
return driver.execute_script(
|
240
|
'''
|
241
|
return (document.getElementById("tree-json") || {}).innerText;
|
242
|
''')
|
243
|
|
244
|
for attempt in range(10):
|
245
|
json_txt = run_content_script()
|
246
|
if json.loads(json_txt) == {}:
|
247
|
break;
|
248
|
assert attempt != 9
|
249
|
|
250
|
driver.switch_to.window(windows[0])
|
251
|
execute_in_page(load_script('common/indexeddb.js'))
|
252
|
|
253
|
sample_data = {
|
254
|
'mappings': dict([(sm['identifier'], {'1.0': sm})
|
255
|
for sm in sample_mappings]),
|
256
|
'resources': {},
|
257
|
'files': {}
|
258
|
}
|
259
|
execute_in_page('returnval(save_items(arguments[0]));', sample_data)
|
260
|
|
261
|
for attempt in range(10):
|
262
|
tree_json = run_content_script()
|
263
|
json.loads(tree_json)
|
264
|
if all([m['identifier'] in tree_json for m in sample_mappings]):
|
265
|
break
|
266
|
assert attempt != 9
|
267
|
|
268
|
driver.switch_to.window(windows[0])
|
269
|
execute_in_page(
|
270
|
'''{
|
271
|
const identifiers = arguments[0];
|
272
|
async function remove_items()
|
273
|
{
|
274
|
const ctx = await start_items_transaction(["mapping"], {});
|
275
|
for (const id of identifiers)
|
276
|
await remove_mapping(id, ctx);
|
277
|
await finalize_transaction(ctx);
|
278
|
}
|
279
|
returnval(remove_items());
|
280
|
}''',
|
281
|
[sm['identifier'] for sm in sample_mappings])
|
282
|
|
283
|
for attempt in range(10):
|
284
|
if json.loads(run_content_script()) == {}:
|
285
|
break
|
286
|
assert attempt != 9
|