Project

General

Profile

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

haketilo / test / unit / test_patterns_query_manager.py @ 9d825eaa

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
(14-14/25)