Project

General

Profile

Download (11.4 KB) Statistics
| Branch: | Tag: | Revision:

haketilo / test / unit / test_patterns_query_manager.py @ 92fc67cf

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
from selenium.common.exceptions import TimeoutException
24

    
25
from ..script_loader import load_script
26

    
27
def simple_sample_mapping(patterns, fruit):
28
    if type(patterns) is not list:
29
        patterns = [patterns]
30
    payloads = dict([(p, {'identifier': f'{fruit}-{p}'}) for p in patterns])
31
    return {
32
        'source_copyright': [],
33
        'type': 'mapping',
34
        'identifier': f'inject-{fruit}',
35
        'payloads': payloads
36
    }
37

    
38
def get_content_script_values(driver, content_script):
39
    """
40
    Allow easy extraction of 'this.something = ...' values from generated
41
    content script and verify the content script is syntactically correct.
42
    """
43
    return driver.execute_script(
44
        '''
45
        function value_holder() {
46
            %s;
47
            return this;
48
        }
49
        return value_holder.call({});
50
        ''' % content_script)
51

    
52
# Fields that are not relevant for testing are omitted from these mapping
53
# definitions.
54
sample_mappings = [simple_sample_mapping(pats, fruit) for pats, fruit in [
55
    (['https://gotmyowndoma.in/index.html',
56
      'http://gotmyowndoma.in/index.html'], 'banana'),
57
    (['https://***.gotmyowndoma.in/index.html',
58
      'https://**.gotmyowndoma.in/index.html',
59
      'https://*.gotmyowndoma.in/index.html',
60
      'https://gotmyowndoma.in/index.html'], 'orange'),
61
    ('https://gotmyowndoma.in/index.html/***', 'grape'),
62
    ('http://gotmyowndoma.in/index.html/***', 'melon'),
63
    ('https://gotmyowndoma.in/index.html', 'peach'),
64
    ('https://gotmyowndoma.in/*', 'pear'),
65
    ('https://gotmyowndoma.in/**', 'raspberry'),
66
    ('https://gotmyowndoma.in/***', 'strawberry'),
67
    ('https://***.gotmyowndoma.in/index.html', 'apple'),
68
    ('https://***.gotmyowndoma.in/*', 'avocado'),
69
    ('https://***.gotmyowndoma.in/**', 'papaya'),
70
    ('https://***.gotmyowndoma.in/***', 'kiwi')
71
]]
72

    
73
sample_blocking = [f'http{s}://{dw}gotmyown%sdoma.in{i}{pw}'
74
                   for dw in ('', '***.', '**.', '*.')
75
                   for i in ('/index.html', '')
76
                   for pw in ('', '/', '/*')
77
                   for s in ('', 's')]
78
sample_blocking = [{'pattern': pattern % (i if i > 1 else ''),
79
                    'allow': bool(i & 1)}
80
                   for i, pattern in enumerate(sample_blocking)]
81

    
82
# Even though patterns_query_manager.js is normally meant to run from background
83
# page, some tests can be as well performed running it from a normal page.
84
@pytest.mark.get_page('https://gotmyowndoma.in')
85
def test_pqm_tree_building(driver, execute_in_page):
86
    """
87
    patterns_query_manager.js tracks Haketilo's internal database and builds a
88
    constantly-updated pattern tree based on its contents. Mock the database and
89
    verify tree building works properly.
90
    """
91
    execute_in_page(load_script('background/patterns_query_manager.js'))
92
    # Mock IndexedDB and build patterns tree.
93
    execute_in_page(
94
        '''
95
        const [initial_mappings, initial_blocking] = arguments.slice(0, 2);
96
        let mappingchange, blockingchange, settingchange;
97

    
98
        haketilodb.track.mapping = function (cb) {
99
            mappingchange = cb;
100

    
101
            return [{}, initial_mappings];
102
        }
103
        haketilodb.track.blocking = function (cb) {
104
            blockingchange = cb;
105

    
106
            return [{}, initial_blocking];
107
        }
108
        haketilodb.track.setting = function (cb) {
109
            settingchange = cb;
110

    
111
            return [{}, [{name: "default_allow", value: true}]];
112
        }
113

    
114
        let last_script;
115
        let unregister_called = 0;
116
        async function register_mock(injection)
117
        {
118
            await new Promise(resolve => setTimeout(resolve, 1));
119
            last_script = injection.js[0].code;
120
            return {unregister: () => unregister_called++};
121
        }
122
        browser = {contentScripts: {register: register_mock}};
123

    
124
        returnval(start("abracadabra"));
125
        ''',
126
        sample_mappings[0:2], sample_blocking[0:2])
127

    
128
    found, tree, content_script, deregistrations = execute_in_page(
129
        '''
130
        returnval([pqt.search(tree, arguments[0]).next().value,
131
                   tree, last_script, unregister_called]);
132
        ''',
133
        'https://gotmyowndoma.in/index.html')
134
    best_pattern = 'https://gotmyowndoma.in/index.html'
135
    assert found == \
136
        dict([('~allow', 1),
137
              *[(f'inject-{fruit}', {'identifier': f'{fruit}-{best_pattern}'})
138
                for fruit in ('banana', 'orange')]])
139
    cs_values = get_content_script_values(driver, content_script)
140
    assert cs_values['haketilo_secret']        == 'abracadabra'
141
    assert cs_values['haketilo_pattern_tree']  == tree
142
    assert cs_values['haketilo_default_allow'] == True
143
    assert deregistrations == 0
144

    
145
    def condition_all_added(driver):
146
        last_script = execute_in_page('returnval(last_script);')
147
        cs_values = get_content_script_values(driver, last_script)
148
        nums = [i for i in range(len(sample_blocking)) if i > 1]
149
        return (cs_values['haketilo_default_allow'] == False and
150
                all([('gotmyown%sdoma' % i) in last_script for i in nums]) and
151
                all([m['identifier'] in last_script for m in sample_mappings]))
152

    
153
    execute_in_page(
154
        '''{
155
        const new_setting_val = {name: "default_allow", value: false};
156
        settingchange({key: "default_allow", new_val: new_setting_val});
157
        for (const mapping of arguments[0])
158
            mappingchange({key: mapping.identifier, new_val: mapping});
159
        for (const blocking of arguments[1])
160
            blockingchange({key: blocking.pattern, new_val: blocking});
161
        }''',
162
        sample_mappings[2:], sample_blocking[2:])
163
    WebDriverWait(driver, 10).until(condition_all_added)
164

    
165
    odd_mappings = \
166
        [m['identifier'] for i, m in enumerate(sample_mappings) if i & 1]
167
    odd_blocking = \
168
        [b['pattern'] for i, b in enumerate(sample_blocking) if i & 1]
169
    even_mappings = \
170
        [m['identifier'] for i, m in enumerate(sample_mappings) if 1 - i & 1]
171
    even_blocking = \
172
        [b['pattern'] for i, b in enumerate(sample_blocking) if 1 - i & 1]
173

    
174
    def condition_odd_removed(driver):
175
        last_script = execute_in_page('returnval(last_script);')
176
        nums = [i for i in range(len(sample_blocking)) if i > 1 and 1 - i & 1]
177
        return (all([id not in last_script for id in odd_mappings]) and
178
                all([id in last_script for id in even_mappings]) and
179
                all([p not in last_script for p in odd_blocking[1:]]) and
180
                all([('gotmyown%sdoma' % i) in last_script for i in nums]))
181

    
182
    def condition_all_removed(driver):
183
        content_script = execute_in_page('returnval(last_script);')
184
        cs_values = get_content_script_values(driver, content_script)
185
        return cs_values['haketilo_pattern_tree'] == {}
186

    
187
    execute_in_page(
188
        '''
189
        arguments[0].forEach(identifier => mappingchange({key: identifier}));
190
        arguments[1].forEach(pattern => blockingchange({key: pattern}));
191
        ''',
192
        odd_mappings, odd_blocking)
193

    
194
    WebDriverWait(driver, 10).until(condition_odd_removed)
195

    
196
    execute_in_page(
197
        '''
198
        arguments[0].forEach(identifier => mappingchange({key: identifier}));
199
        arguments[1].forEach(pattern => blockingchange({key: pattern}));
200
        ''',
201
        even_mappings, even_blocking)
202

    
203
    WebDriverWait(driver, 10).until(condition_all_removed)
204

    
205
    def condition_default_allowed_again(driver):
206
        content_script = execute_in_page('returnval(last_script);')
207
        cs_values = get_content_script_values(driver, content_script)
208
        return cs_values['haketilo_default_allow'] == True
209

    
210
    execute_in_page(
211
        '''{
212
        const new_setting_val = {name: "default_allow", value: true};
213
        settingchange({key: "default_allow", new_val: new_setting_val});
214
        }''')
215

    
216
    WebDriverWait(driver, 10).until(condition_default_allowed_again)
217

    
218
content_js = '''
219
let already_run = false;
220
this.haketilo_content_script_main = function() {
221
    if (already_run)
222
        return;
223
    already_run = true;
224
    document.documentElement.innerHTML = "<body><div id='tree-json'>";
225
    document.getElementById("tree-json").innerText =
226
        JSON.stringify(this.haketilo_pattern_tree);
227
}
228
if (this.haketilo_pattern_tree !== undefined)
229
    this.haketilo_content_script_main();
230
'''
231

    
232
def background_js():
233
    pqm_js = load_script('background/patterns_query_manager.js',
234
                         "#IMPORT background/broadcast_broker.js")
235
    return pqm_js + '; broadcast_broker.start(); start();'
236

    
237
@pytest.mark.ext_data({
238
    'content_script':    content_js,
239
    'background_script': background_js
240
})
241
@pytest.mark.usefixtures('webextension')
242
def test_pqm_script_injection(driver, execute_in_page):
243
    # Let's open a normal page in a second window. Window 0 will be used to make
244
    # changes to IndexedDB and window 1 to test the working of content scripts.
245
    driver.execute_script('window.open("about:blank", "_blank");')
246
    WebDriverWait(driver, 10).until(lambda d: len(d.window_handles) == 2)
247
    windows = [*driver.window_handles]
248

    
249
    def get_tree_json(driver):
250
        return driver.execute_script(
251
            '''
252
            return (document.getElementById("tree-json") || {}).innerText;
253
            ''')
254

    
255
    def run_content_script():
256
        driver.switch_to.window(windows[1])
257
        driver.get('https://gotmyowndoma.in/index.html')
258
        windows[1] = driver.current_window_handle
259
        try:
260
            return WebDriverWait(driver, 10).until(get_tree_json)
261
        except TimeoutException:
262
            pass
263

    
264
    for attempt in range(2):
265
        json_txt = run_content_script()
266
        if json_txt and json.loads(json_txt) == {}:
267
            break;
268
        assert attempt != 1
269

    
270
    driver.switch_to.window(windows[0])
271
    execute_in_page(load_script('common/indexeddb.js'))
272

    
273
    sample_data = {
274
        'mapping': dict([(sm['identifier'], {'1.0': sm})
275
                         for sm in sample_mappings]),
276
        'resource': {},
277
        'file': {}
278
    }
279
    execute_in_page('returnval(save_items(arguments[0]));', sample_data)
280

    
281
    for attempt in range(2):
282
        tree_json = run_content_script() or '{}'
283
        json.loads(tree_json)
284
        if all([m['identifier'] in tree_json for m in sample_mappings]):
285
            break
286
        assert attempt != 1
287

    
288
    driver.switch_to.window(windows[0])
289
    execute_in_page(
290
        '''{
291
        const identifiers = arguments[0];
292
        async function remove_items()
293
        {
294
            const ctx = await start_items_transaction(["mapping"], {});
295
            for (const id of identifiers)
296
                await remove_mapping(id, ctx);
297
            await finalize_transaction(ctx);
298
        }
299
        returnval(remove_items());
300
        }''',
301
        [sm['identifier'] for sm in sample_mappings])
302

    
303
    for attempt in range(2):
304
        json_txt = run_content_script()
305
        if json_txt and json.loads(json_txt) == {}:
306
            break;
307
        assert attempt != 1
(14-14/25)