Project

General

Profile

« Previous | Next » 

Revision 5c58b3d6

Added by koszko over 1 year ago

facilitate querying IndexedDB for script files of resource and its dependencies

View differences:

background/indexeddb_files_server.js
1
/**
2
 * This file is part of Haketilo.
3
 *
4
 * Function: Allow content scripts to query IndexedDB through messages to
5
 *           background script.
6
 *
7
 * Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org>
8
 *
9
 * This program is free software: you can redistribute it and/or modify
10
 * it under the terms of the GNU General Public License as published by
11
 * the Free Software Foundation, either version 3 of the License, or
12
 * (at your option) any later version.
13
 *
14
 * This program is distributed in the hope that it will be useful,
15
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17
 * GNU General Public License for more details.
18
 *
19
 * As additional permission under GNU GPL version 3 section 7, you
20
 * may distribute forms of that code without the copy of the GNU
21
 * GPL normally required by section 4, provided you include this
22
 * license notice and, in case of non-source distribution, a URL
23
 * through which recipients can access the Corresponding Source.
24
 * If you modify file(s) with this exception, you may extend this
25
 * exception to your version of the file(s), but you are not
26
 * obligated to do so. If you do not wish to do so, delete this
27
 * exception statement from your version.
28
 *
29
 * As a special exception to the GPL, any HTML file which merely
30
 * makes function calls to this code, and for that purpose
31
 * includes it by reference shall be deemed a separate work for
32
 * copyright law purposes. If you modify this code, you may extend
33
 * this exception to your version of the code, but you are not
34
 * obligated to do so. If you do not wish to do so, delete this
35
 * exception statement from your version.
36
 *
37
 * You should have received a copy of the GNU General Public License
38
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
39
 *
40
 * I, Wojtek Kosior, thereby promise not to sue for violation of this file's
41
 * license. Although I request that you do not make use of this code in a
42
 * proprietary program, I am not going to enforce this in court.
43
 */
44

  
45
#IMPORT common/indexeddb.js AS haketilodb
46

  
47
#FROM common/browser.js IMPORT browser
48

  
49
async function get_resource_files(getting, id) {
50
    if (getting.defs_by_res_id.has(id))
51
	return;
52

  
53
    getting.defs_by_res_id.set(id, null);
54

  
55
    const definition = await haketilodb.idb_get(getting.tx, "resource", id);
56
    if (!definition)
57
	throw {haketilo_error_type: "missing", id};
58

  
59
    getting.defs_by_res_id.set(id, definition);
60

  
61
    const file_proms = (definition.scripts || [])
62
	  .map(s => haketilodb.idb_get(getting.tx, "files", s.hash_key));
63

  
64
    const deps_proms = (definition.dependencies || [])
65
	  .map(dep_id => get_resource_files(getting, dep_id));
66

  
67
    const files = (await Promise.all(file_proms)).map(f => f.contents);
68
    getting.files_by_res_id.set(id, files);
69

  
70
    await Promise.all(deps_proms);
71
}
72

  
73
function get_files_list(defs_by_res_id, files_by_res_id, root_id) {
74
    const processed = new Set(), to_process = [["start", root_id]],
75
	  trace = new Set(), files = [];
76

  
77
    while (to_process.length > 0) {
78
	const [what, id] = to_process.pop();
79
	if (what === "end") {
80
	    trace.delete(id);
81
	    files.push(...files_by_res_id.get(id));
82
	    continue;
83
	}
84

  
85
	if (trace.has(id))
86
	    throw {haketilo_error_type: "circular", id};
87

  
88
	if (processed.has(id))
89
	    continue;
90

  
91
	trace.add(id);
92
	to_process.push(["end", id]);
93
	processed.add(id);
94

  
95
	const ds = (defs_by_res_id.get(id).dependencies || []).reverse();
96
	ds.forEach(dep_id => to_process.push(["start", dep_id]));
97
    }
98

  
99
    return files;
100
}
101

  
102
async function send_resource_files(root_resource_id, send_cb) {
103
    const db = await haketilodb.get();
104
    const getting = {
105
	defs_by_res_id:  new Map(),
106
	files_by_res_id: new Map(),
107
	tx:              db.transaction(["files", "resource"])
108
    };
109

  
110
    let prom_cbs, prom = new Promise((...cbs) => prom_cbs = cbs);
111

  
112
    getting.tx.onerror = e => prom_cbs[1]({haketilo_error_type: "db", e});
113

  
114
    get_resource_files(getting, root_resource_id, new Set()).then(...prom_cbs);
115

  
116
    try {
117
	await prom;
118
	const files = get_files_list(
119
	    getting.defs_by_res_id,
120
	    getting.files_by_res_id,
121
	    root_resource_id
122
	);
123
	var to_send = {files};
124
    } catch(e) {
125
	if (typeof e === "object" && "haketilo_error_type" in e) {
126
	    if (e.haketilo_error_type === "db") {
127
		console.error(e.e);
128
		delete e.e;
129
	    }
130
	    var to_send = {error: e};
131
	} else {
132
	    console.error(e);
133
	    var to_send = {error: {haketilo_error_type: "other"}};
134
	}
135
    }
136

  
137
    send_cb(to_send);
138
}
139

  
140
function on_indexeddb_files_request([type, resource_id], sender, respond_cb) {
141
    if (type !== "indexeddb_files")
142
	return;
143

  
144
    send_resource_files(resource_id, respond_cb);
145

  
146
    return true;
147
}
148

  
149
function start() {
150
    browser.runtime.onMessage.addListener(on_indexeddb_files_request);
151
}
152
#EXPORT start
common/message_server.js
106 106
    listeners[magic](ports[0]);
107 107
    return ports[1];
108 108
}
109

  
110 109
#EXPORT connect_to_background
test/unit/test_indexeddb.py
51 51
        'identifier': 'helloapple'
52 52
    }
53 53

  
54
def mock_broadcast(execute_in_page):
55
    execute_in_page(
56
        '''{
57
        const broadcast_mock = {};
58
        const nop = () => {};
59
        for (const key in broadcast)
60
            broadcast_mock[key] = nop;
61
        broadcast = broadcast_mock;
62
        }''')
63

  
64 54
@pytest.mark.get_page('https://gotmyowndoma.in')
65 55
def test_haketilodb_item_modifications(driver, execute_in_page):
66 56
    """
test/unit/test_indexeddb_files_server.py
1
# SPDX-License-Identifier: CC0-1.0
2

  
3
"""
4
Haketilo unit tests - serving indexeddb resource script files to content scripts
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 copy
22
from uuid import uuid4
23
from selenium.webdriver.support.ui import WebDriverWait
24

  
25
from ..script_loader import load_script
26
from .utils import *
27

  
28
"""
29
How many test resources we're going to have.
30
"""
31
count = 15
32

  
33
sample_files_list = [(f'file_{n}_{i}', f'contents {n} {i}')
34
                       for n in range(count) for i in range(2)]
35

  
36
sample_files = dict(sample_files_list)
37

  
38
sample_files, sample_files_by_hash = make_sample_files(sample_files)
39

  
40
def make_sample_resource_with_deps(n):
41
    resource = make_sample_resource(with_files=False)
42

  
43
    resource['identifier'] = f'res-{n}'
44
    resource['dependencies'] = [f'res-{m}'
45
                                for m in range(max(n - 4, 0), n)]
46
    resource['scripts'] = [sample_file_ref(f'file_{n}_{i}', sample_files)
47
                           for i in range(2)]
48

  
49
    return resource
50

  
51
resources = [make_sample_resource_with_deps(n) for n in range(count)]
52

  
53
sample_data = {
54
    'resources': sample_data_dict(resources),
55
    'mapping': {},
56
    'files': sample_files_by_hash
57
}
58

  
59
def prepare_test_page(initial_indexeddb_data, execute_in_page):
60
    js = load_script('background/indexeddb_files_server.js',
61
                     code_to_add='#IMPORT common/broadcast.js')
62
    execute_in_page(js)
63

  
64
    mock_broadcast(execute_in_page)
65
    clear_indexeddb(execute_in_page)
66

  
67
    execute_in_page(
68
        '''
69
        let registered_listener;
70
        const new_addListener = cb => registered_listener = cb;
71

  
72
        browser = {runtime: {onMessage: {addListener: new_addListener}}};
73

  
74
        haketilodb.save_items(arguments[0]);
75

  
76
        start();
77
        ''',
78
        initial_indexeddb_data)
79

  
80
@pytest.mark.get_page('https://gotmyowndoma.in')
81
def test_indexeddb_files_server_normal_usage(driver, execute_in_page):
82
    """
83
    Test querying resource files (with resource dependency resolution)
84
    from IndexedDB and serving them in messages to content scripts.
85
    """
86
    prepare_test_page(sample_data, execute_in_page)
87

  
88
    # Verify other types of messages are ignored.
89
    function_returned_value = execute_in_page(
90
        '''
91
        returnval(registered_listener(["???"], {},
92
                  () => location.reload()));
93
        ''')
94
    assert function_returned_value == None
95

  
96
    # Verify single resource's files get properly resolved.
97
    function_returned_value = execute_in_page(
98
        '''
99
        var result_cb, contents_prom = new Promise(cb => result_cb = cb);
100

  
101
        returnval(registered_listener(["indexeddb_files", "res-0"],
102
                                      {}, result_cb));
103
        ''')
104
    assert function_returned_value == True
105

  
106
    assert execute_in_page('returnval(contents_prom);') == \
107
        {'files': [tuple[1] for tuple in sample_files_list[0:2]]}
108

  
109
    # Verify multiple resources' files get properly resolved.
110
    function_returned_value = execute_in_page(
111
        '''
112
        var result_cb, contents_prom = new Promise(cb => result_cb = cb);
113

  
114
        returnval(registered_listener(["indexeddb_files", arguments[0]],
115
                                      {}, result_cb));
116
        ''',
117
        f'res-{count - 1}')
118
    assert function_returned_value == True
119

  
120
    assert execute_in_page('returnval(contents_prom);') == \
121
        {'files': [tuple[1] for tuple in sample_files_list]}
122

  
123
@pytest.mark.get_page('https://gotmyowndoma.in')
124
@pytest.mark.parametrize('error', [
125
    'missing',
126
    'circular',
127
    'db',
128
    'other'
129
])
130
def test_indexeddb_files_server_errors(driver, execute_in_page, error):
131
    """
132
    Test reporting of errors when querying resource files (with resource
133
    dependency resolution) from IndexedDB and serving them in messages to
134
    content scripts.
135
    """
136
    sample_data_copy = copy.deepcopy(sample_data)
137

  
138
    if error == 'missing':
139
        del sample_data_copy['resources']['res-3']
140
    elif error == 'circular':
141
        res3_defs = sample_data_copy['resources']['res-3'].values()
142
        next(iter(res3_defs))['dependencies'].append('res-8')
143

  
144
    prepare_test_page(sample_data_copy, execute_in_page)
145

  
146
    if error == 'db':
147
        execute_in_page('haketilodb.idb_get = t => t.onerror("oooops");')
148
    elif error == 'other':
149
        execute_in_page('haketilodb.idb_get = () => {throw "oooops"};')
150

  
151
    response = execute_in_page(
152
        '''
153
        var result_cb, contents_prom = new Promise(cb => result_cb = cb);
154

  
155
        registered_listener(["indexeddb_files", arguments[0]],
156
                            {}, result_cb);
157

  
158
        returnval(contents_prom);
159
        ''',
160
        f'res-{count - 1}')
161

  
162
    assert response['error']['haketilo_error_type'] == error
163

  
164
    if error == 'missing':
165
        assert response['error']['id'] == 'res-3'
166
    elif error == 'circular':
167
        assert response['error']['id'] in ('res-3', 'res-8')
168
    elif error not in ('db', 'other'):
169
        raise Exception('made a typo in test function params?')
test/unit/utils.py
42 42
        'contents': contents
43 43
    }
44 44

  
45
sample_files = {
46
    'report.spdx':              sample_file('<!-- dummy report -->'),
47
    'LICENSES/somelicense.txt': sample_file('Permission is granted...'),
48
    'LICENSES/CC0-1.0.txt':     sample_file('Dummy Commons...'),
49
    'hello.js':                 sample_file('console.log("uńićódę hello!");\n'),
50
    'bye.js':                   sample_file('console.log("bye!");\n'),
51
    'combined.js':              sample_file('console.log("hello!\\nbye!");\n'),
52
    'README.md':                sample_file('# Python Frobnicator\n...')
53
}
54

  
55
sample_files_by_hash = dict([[file['hash_key'], file['contents']]
56
                             for file in sample_files.values()])
57

  
58
def sample_file_ref(file_name):
59
    return {'file': file_name, 'hash_key': sample_files[file_name]['hash_key']}
60

  
61
def make_sample_mapping():
45
def make_sample_files(names_contents):
46
    """
47
    Take a dict mapping file names to file contents. Return, as a tuple, dicts
48
    mapping file names to file objects (dicts) and file hash keys to file
49
    contents.
50
    """
51
    sample_files = dict([(name, sample_file(contents))
52
                         for name, contents in names_contents.items()])
53

  
54
    sample_files_by_hash = dict([[file['hash_key'], file['contents']]
55
                                 for file in sample_files.values()])
56

  
57
    return sample_files, sample_files_by_hash
58

  
59
sample_files, sample_files_by_hash = make_sample_files({
60
    'report.spdx':              '<!-- dummy report -->',
61
    'LICENSES/somelicense.txt': 'Permission is granted...',
62
    'LICENSES/CC0-1.0.txt':     'Dummy Commons...',
63
    'hello.js':                 'console.log("uńićódę hello!");\n',
64
    'bye.js':                   'console.log("bye!");\n',
65
    'combined.js':              'console.log("hello!\\nbye!");\n',
66
    'README.md':                '# Python Frobnicator\n...'
67
})
68

  
69
def sample_file_ref(file_name, sample_files_dict=sample_files):
70
    """
71
    Return a dictionary suitable for using as file reference in resource/mapping
72
    definition.
73
    """
74
    return {
75
        'file': file_name,
76
        'hash_key': sample_files_dict[file_name]['hash_key']
77
    }
78

  
79
def make_sample_mapping(with_files=True):
80
    """
81
    Procude a sample mapping definition that can be dumped to JSON and put into
82
    Haketilo's IndexedDB.
83
    """
62 84
    return {
63 85
        'source_name': 'example-org-fixes-new',
64 86
        'source_copyright': [
65 87
            sample_file_ref('report.spdx'),
66 88
            sample_file_ref('LICENSES/CC0-1.0.txt')
67
        ],
89
        ] if with_files else [],
68 90
        'type': 'mapping',
69 91
        'identifier': 'example-org-minimal',
70 92
        'long_name': 'Example.org Minimal',
......
81 103
        }
82 104
    }
83 105

  
84
def make_sample_resource():
106
def make_sample_resource(with_files=True):
107
    """
108
    Procude a sample resource definition that can be dumped to JSON and put into
109
    Haketilo's IndexedDB.
110
    """
85 111
    return {
86 112
        'source_name': 'hello',
87 113
        'source_copyright': [
88 114
            sample_file_ref('report.spdx'),
89 115
            sample_file_ref('LICENSES/CC0-1.0.txt')
90
        ],
116
        ] if with_files else [],
91 117
        'type': 'resource',
92 118
        'identifier': 'helloapple',
93 119
        'long_name': 'Hello Apple',
......
99 125
        'scripts': [
100 126
            sample_file_ref('hello.js'),
101 127
            sample_file_ref('bye.js')
102
        ]
128
        ] if with_files else []
103 129
    }
104 130

  
105 131
def item_version_string(definition, include_revision=False):
......
113 139

  
114 140
def sample_data_dict(items):
115 141
    """
116
    Some indexeddb functions expect saved items to be provided in a nested dict
142
    Some IndexedDB functions expect saved items to be provided in a nested dict
117 143
    that makes them queryable by identifier by version. This function converts
118 144
    items list to such dict.
119 145
    """
......
202 228
            ''',
203 229
            nonce)
204 230

  
231
def mock_broadcast(execute_in_page):
232
    """
233
    Make all broadcast operations no-ops (broadcast must be imported).
234
    """
235
    execute_in_page(
236
        'Object.keys(broadcast).forEach(k => broadcast[k] = () => {});'
237
    )
238

  
205 239
def mock_cacher(execute_in_page):
206 240
    """
207 241
    Some parts of code depend on content/repo_query_cacher.js and

Also available in: Unified diff