1
|
# SPDX-License-Identifier: CC0-1.0
|
2
|
|
3
|
# Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org>
|
4
|
#
|
5
|
# Available under the terms of Creative Commons Zero v1.0 Universal.
|
6
|
|
7
|
# Enable using with Python 3.7.
|
8
|
from __future__ import annotations
|
9
|
|
10
|
import pytest
|
11
|
import tempfile
|
12
|
import re
|
13
|
from pathlib import Path
|
14
|
from zipfile import ZipFile
|
15
|
#from hashlib import sha256
|
16
|
|
17
|
from hydrilla.builder import local_apt
|
18
|
|
19
|
here = Path(__file__).resolve().parent
|
20
|
|
21
|
@pytest.fixture(autouse=True)
|
22
|
def no_requests(monkeypatch):
|
23
|
"""Remove requests.sessions.Session.request for all tests."""
|
24
|
monkeypatch.delattr('requests.sessions.Session.request')
|
25
|
|
26
|
@pytest.fixture
|
27
|
def mock_cache_dir(monkeypatch):
|
28
|
"""Make local_apt.py cache files to a temporary directory."""
|
29
|
with tempfile.TemporaryDirectory() as td:
|
30
|
td_path = Path(td)
|
31
|
monkeypatch.setattr(local_apt, 'default_apt_cache_dir', td_path)
|
32
|
yield td_path
|
33
|
|
34
|
@pytest.fixture
|
35
|
def mock_gnupg_import(monkeypatch, mock_cache_dir):
|
36
|
"""Mock gnupg library when imported dynamically."""
|
37
|
|
38
|
gnupg_mock_dir = mock_cache_dir / 'gnupg_mock'
|
39
|
gnupg_mock_dir.mkdir()
|
40
|
(gnupg_mock_dir / 'gnupg.py').write_text('GPG = None\n')
|
41
|
|
42
|
monkeypatch.syspath_prepend(str(gnupg_mock_dir))
|
43
|
|
44
|
import gnupg
|
45
|
|
46
|
keyring_path = mock_cache_dir / 'master_keyring.gpg'
|
47
|
|
48
|
class MockedImportResult:
|
49
|
"""gnupg.ImportResult replacement"""
|
50
|
def __init__(self):
|
51
|
"""Initialize MockedImportResult object."""
|
52
|
self.imported = 1
|
53
|
|
54
|
class MockedGPG:
|
55
|
"""GPG replacement that does not really invoke GPG."""
|
56
|
def __init__(self, keyring):
|
57
|
"""Verify the keyring path and initialize MockedGPG."""
|
58
|
assert keyring == str(keyring_path)
|
59
|
|
60
|
self.known_keys = {*keyring_path.read_text().split('\n')} \
|
61
|
if keyring_path.exists() else set()
|
62
|
|
63
|
def recv_keys(self, keyserver, key):
|
64
|
"""Mock key receiving - record requested key as received."""
|
65
|
assert keyserver == local_apt.default_keyserver
|
66
|
assert key not in self.known_keys
|
67
|
|
68
|
self.known_keys.add(key)
|
69
|
keyring_path.write_text('\n'.join(self.known_keys))
|
70
|
|
71
|
return MockedImportResult()
|
72
|
|
73
|
def list_keys(self, keys=None):
|
74
|
"""Mock key listing - return a list with dummy items."""
|
75
|
if keys is None:
|
76
|
return ['dummy'] * len(self.known_keys)
|
77
|
else:
|
78
|
return ['dummy' for k in keys if k in self.known_keys]
|
79
|
|
80
|
def export_keys(self, keys, **kwargs):
|
81
|
"""
|
82
|
Mock key export - check that the call has the expected arguments and
|
83
|
return a dummy bytes array.
|
84
|
"""
|
85
|
assert kwargs['armor'] == False
|
86
|
assert kwargs['minimal'] == True
|
87
|
assert {*keys} == self.known_keys
|
88
|
|
89
|
return b'<dummy keys export>'
|
90
|
|
91
|
monkeypatch.setattr(gnupg, 'GPG', MockedGPG)
|
92
|
|
93
|
class MockedCompletedProcess:
|
94
|
"""
|
95
|
Object with some fields similar to those of subprocess.CompletedProcess.
|
96
|
"""
|
97
|
def __init__(self, args, returncode, stdout, stderr):
|
98
|
"""Initialize MockedCompletedProcess"""
|
99
|
self.args = args
|
100
|
self.returncode = returncode
|
101
|
self.stdout = stdout
|
102
|
self.stderr = stderr
|
103
|
|
104
|
"""
|
105
|
Output of 'apt-get install --yes --just-print libjs-mathjax' on some APT-based
|
106
|
system.
|
107
|
"""
|
108
|
sample_install_stdout = '''\
|
109
|
NOTE: This is only a simulation!
|
110
|
apt-get needs root privileges for real execution.
|
111
|
Keep also in mind that locking is deactivated,
|
112
|
so don't depend on the relevance to the real current situation!
|
113
|
Reading package lists...
|
114
|
Building dependency tree...
|
115
|
Reading state information...
|
116
|
The following additional packages will be installed:
|
117
|
fonts-mathjax
|
118
|
Suggested packages:
|
119
|
fonts-mathjax-extras fonts-stix libjs-mathjax-doc
|
120
|
The following NEW packages will be installed:
|
121
|
fonts-mathjax libjs-mathjax
|
122
|
0 upgraded, 2 newly installed, 0 to remove and 0 not upgraded.
|
123
|
Inst fonts-mathjax (2.7.9+dfsg-1 Devuan:4.0/stable, Devuan:1.0.0/unstable [all])
|
124
|
Inst libjs-mathjax (2.7.9+dfsg-1 Devuan:4.0/stable, Devuan:1.0.0/unstable [all])
|
125
|
Conf fonts-mathjax (2.7.9+dfsg-1 Devuan:4.0/stable, Devuan:1.0.0/unstable [all])
|
126
|
Conf libjs-mathjax (2.7.9+dfsg-1 Devuan:4.0/stable, Devuan:1.0.0/unstable [all])
|
127
|
'''
|
128
|
|
129
|
class MockedSubprocess:
|
130
|
"""subprocess with a run() function that does not spawn a process."""
|
131
|
def __init__(self, update_return_value=0, install_return_value=0,
|
132
|
download_return_value=0, source_return_value=0):
|
133
|
"""Initialize MockedSubprocess object."""
|
134
|
self.update_return_value = update_return_value
|
135
|
self.install_return_value = install_return_value
|
136
|
self.download_return_value = download_return_value
|
137
|
self.source_return_value = source_return_value
|
138
|
|
139
|
def run(self, command, **kwargs):
|
140
|
"""
|
141
|
Instead of running an 'apt-get update' command just touch some file
|
142
|
in apt root to indicate that the call was made. Instead of running an
|
143
|
'apt-get install' command just print a possible output of one. Instead
|
144
|
if running an 'apt-get download' command just write some dummy .deb
|
145
|
files to the appropriate directory.
|
146
|
"""
|
147
|
assert kwargs['env'] == {'LANG': 'en_US'}
|
148
|
assert kwargs['capture_output'] == True
|
149
|
|
150
|
if 'update' in command:
|
151
|
expected_command = ['apt-get', '-c', '<conf_path>', 'update']
|
152
|
elif 'install' in command:
|
153
|
expected_command = ['apt-get', '-c', '<conf_path>', 'install',
|
154
|
'--yes', '--just-print', 'libjs-mathjax']
|
155
|
elif 'download' in command:
|
156
|
expected_command = ['apt-get', '-c', '<conf_path>', 'download',
|
157
|
'libjs-mathjax']
|
158
|
if 'fonts-mathjax' in command:
|
159
|
expected_command.insert(-1, 'fonts-mathjax')
|
160
|
elif 'source' in command:
|
161
|
expected_command = ['apt-get', '-c', '<conf_path>', 'source',
|
162
|
'--download-only', 'libjs-mathjax=2.7.9+dfsg-1']
|
163
|
if 'fonts-mathjax=2.7.9+dfsg-1' in command:
|
164
|
if command[-1] == 'fonts-mathjax=2.7.9+dfsg-1':
|
165
|
expected_command.append('fonts-mathjax=2.7.9+dfsg-1')
|
166
|
else:
|
167
|
expected_command.insert(-1, 'fonts-mathjax=2.7.9+dfsg-1')
|
168
|
else:
|
169
|
raise Exception(f'unknown apt command: {" ".join(command)}')
|
170
|
|
171
|
assert len(expected_command) == len(command)
|
172
|
|
173
|
for word, expected in zip(command, expected_command):
|
174
|
if expected == '<conf_path>':
|
175
|
conf_path = Path(word)
|
176
|
else:
|
177
|
assert word == expected
|
178
|
|
179
|
if 'update' in command:
|
180
|
(conf_path.parent / 'update_called').touch()
|
181
|
|
182
|
if 'download' in command:
|
183
|
destination = Path(kwargs.get('cwd') or Path.cwd())
|
184
|
|
185
|
(destination / 'libjs-mathjax_2.7.9+dfsg-1_all.deb')\
|
186
|
.write_text('dummy libjs-mathjax_2.7.9+dfsg-1_all.deb')
|
187
|
|
188
|
if 'fonts-mathjax' in command:
|
189
|
(destination / 'fonts-mathjax_2.7.9+dfsg-1_all.deb')\
|
190
|
.write_text('dummy fonts-mathjax_2.7.9+dfsg-1_all.deb')
|
191
|
|
192
|
if 'source' in command:
|
193
|
destination = Path(kwargs.get('cwd') or Path.cwd())
|
194
|
for filename in [
|
195
|
'mathjax_2.7.9+dfsg-1.debian.tar.xz',
|
196
|
'mathjax_2.7.9+dfsg-1.dsc',
|
197
|
'mathjax_2.7.9+dfsg.orig.tar.xz'
|
198
|
]:
|
199
|
(destination / filename).write_text(f'dummy {filename}')
|
200
|
|
201
|
if kwargs.get('text'):
|
202
|
stderr = 'some error output'
|
203
|
stdout = 'some output'
|
204
|
if 'install' in command:
|
205
|
stdout = sample_install_stdout
|
206
|
else:
|
207
|
stdout = b'some bin output'
|
208
|
stderr = b'some bin error output'
|
209
|
if 'install' in command:
|
210
|
stdout = sample_install_stdout.encode()
|
211
|
|
212
|
return_val = (self.update_return_value if 'update' in command else \
|
213
|
self.install_return_value if 'install' in command else \
|
214
|
self.download_return_value if 'download' in command else \
|
215
|
self.source_return_value)
|
216
|
|
217
|
return MockedCompletedProcess(command, return_val, stdout, stderr)
|
218
|
|
219
|
@pytest.fixture
|
220
|
def mock_apt_get_update(monkeypatch, request):
|
221
|
"""Mock 'apt-get update' command when called through subprocess.run."""
|
222
|
marker = request.node.get_closest_marker('subprocess_run')
|
223
|
run_mock_opts = marker.args[0] if marker else {}
|
224
|
monkeypatch.setattr(local_apt, 'subprocess',
|
225
|
MockedSubprocess(**run_mock_opts))
|
226
|
|
227
|
class MockedSubprocessRaises:
|
228
|
"""subprocess with a run() function that always raises FileNotFoundError."""
|
229
|
def run(self, command, **kwargs):
|
230
|
"""
|
231
|
Instead of running an 'apt-get' command we act as if it was missing.
|
232
|
"""
|
233
|
raise FileNotFoundError('dummy')
|
234
|
|
235
|
@pytest.fixture
|
236
|
def mock_apt_get_update_raises(monkeypatch):
|
237
|
"""Mock missing 'apt-get' command when called through subprocess.run."""
|
238
|
|
239
|
monkeypatch.setattr(local_apt, 'subprocess', MockedSubprocessRaises())
|
240
|
|
241
|
def test_local_apt_contextmanager(mock_cache_dir, mock_apt_get_update,
|
242
|
mock_gnupg_import):
|
243
|
"""
|
244
|
Verify that the local_apt() function creates a proper apt environment and
|
245
|
that it also properly restores it from cache.
|
246
|
"""
|
247
|
sources_list = local_apt.SourcesList(['deb-src sth', 'deb sth'])
|
248
|
|
249
|
with local_apt.local_apt(sources_list, local_apt.default_keys) as apt:
|
250
|
apt_root = Path(apt.apt_conf).parent.parent
|
251
|
|
252
|
assert (apt_root / 'etc' / 'trusted.gpg').read_bytes() == \
|
253
|
b'<dummy keys export>'
|
254
|
|
255
|
assert (apt_root / 'etc' / 'update_called').exists()
|
256
|
|
257
|
assert (apt_root / 'etc' / 'apt.sources.list').read_text() == \
|
258
|
'deb-src sth\ndeb sth'
|
259
|
|
260
|
conf_lines = (apt_root / 'etc' / 'apt.conf').read_text().split('\n')
|
261
|
|
262
|
# check mocked keyring
|
263
|
assert {*local_apt.default_keys} == \
|
264
|
{*(mock_cache_dir / 'master_keyring.gpg').read_text().split('\n')}
|
265
|
|
266
|
assert not apt_root.exists()
|
267
|
|
268
|
expected_conf = {
|
269
|
'Dir': str(apt_root),
|
270
|
'Dir::State': f'{apt_root}/var/lib/apt',
|
271
|
'Dir::State::status': f'{apt_root}/var/lib/dpkg/status',
|
272
|
'Dir::Etc::SourceList': f'{apt_root}/etc/apt.sources.list',
|
273
|
'Dir::Etc::SourceParts': '',
|
274
|
'Dir::Cache': f'{apt_root}/var/cache/apt',
|
275
|
'pkgCacheGen::Essential': 'none',
|
276
|
'Dir::Etc::Trusted': f'{apt_root}/etc/trusted.gpg',
|
277
|
}
|
278
|
|
279
|
conf_regex = re.compile(r'^(?P<key>\S+)\s"(?P<val>\S*)";$')
|
280
|
assert dict([(m.group('key'), m.group('val'))
|
281
|
for l in conf_lines if l for m in [conf_regex.match(l)]]) == \
|
282
|
expected_conf
|
283
|
|
284
|
with ZipFile(mock_cache_dir / f'apt_{sources_list.identity()}.zip') as zf:
|
285
|
# reuse the same APT, its cached zip file should exist now
|
286
|
with local_apt.local_apt(sources_list, local_apt.default_keys) as apt:
|
287
|
apt_root = Path(apt.apt_conf).parent.parent
|
288
|
|
289
|
expected_members = {*apt_root.rglob('*')}
|
290
|
expected_members.remove(apt_root / 'etc' / 'apt.conf')
|
291
|
expected_members.remove(apt_root / 'etc' / 'trusted.gpg')
|
292
|
|
293
|
names = zf.namelist()
|
294
|
assert len(names) == len(expected_members)
|
295
|
|
296
|
for name in names:
|
297
|
path = apt_root / name
|
298
|
assert path in expected_members
|
299
|
assert zf.read(name) == \
|
300
|
(b'' if path.is_dir() else path.read_bytes())
|
301
|
|
302
|
assert not apt_root.exists()
|
303
|
|
304
|
@pytest.mark.subprocess_run({'update_return_value': 1})
|
305
|
def test_local_apt_update_failing(mock_cache_dir, mock_apt_get_update,
|
306
|
mock_gnupg_import):
|
307
|
"""
|
308
|
Verify that the local_apt() function raises a proper error when
|
309
|
'apt-get update' command returns non-0.
|
310
|
"""
|
311
|
sources_list = local_apt.SourcesList(['deb-src sth', 'deb sth'])
|
312
|
|
313
|
with pytest.raises(local_apt.AptError) as excinfo:
|
314
|
with local_apt.local_apt(sources_list, local_apt.default_keys) as apt:
|
315
|
pass
|
316
|
|
317
|
assert len(excinfo.value.args) == 1
|
318
|
|
319
|
assert re.match(r'.*\n\n.*\n\nsome output\n\n.*\n\nsome error output',
|
320
|
excinfo.value.args[0])
|
321
|
|
322
|
def test_local_apt_missing(mock_cache_dir, mock_apt_get_update_raises,
|
323
|
mock_gnupg_import):
|
324
|
"""
|
325
|
Verify that the local_apt() function raises a proper error when 'apt-get'
|
326
|
command is missing.
|
327
|
"""
|
328
|
sources_list = local_apt.SourcesList(['deb-src sth', 'deb sth'])
|
329
|
|
330
|
with pytest.raises(local_apt.AptError) as excinfo:
|
331
|
with local_apt.local_apt(sources_list, local_apt.default_keys) as apt:
|
332
|
pass
|
333
|
|
334
|
assert len(excinfo.value.args) == 1
|
335
|
assert isinstance(excinfo.value.args[0], str)
|
336
|
assert '\n' not in excinfo.value.args[0]
|
337
|
|
338
|
def test_local_apt_download(mock_cache_dir, mock_apt_get_update,
|
339
|
mock_gnupg_import):
|
340
|
"""
|
341
|
Verify that download_apt_packages() function properly performs the download
|
342
|
of .debs and sources.
|
343
|
"""
|
344
|
sources_list = local_apt.SourcesList(['deb-src sth', 'deb sth'])
|
345
|
destination = mock_cache_dir / 'destination'
|
346
|
destination.mkdir()
|
347
|
|
348
|
local_apt.download_apt_packages(sources_list, local_apt.default_keys,
|
349
|
['libjs-mathjax'], destination)
|
350
|
|
351
|
libjs_mathjax_path = destination / 'libjs-mathjax_2.7.9+dfsg-1_all.deb'
|
352
|
fonts_mathjax_path = destination / 'fonts-mathjax_2.7.9+dfsg-1_all.deb'
|
353
|
|
354
|
source_paths = [
|
355
|
destination / 'mathjax_2.7.9+dfsg-1.debian.tar.xz',
|
356
|
destination / 'mathjax_2.7.9+dfsg-1.dsc',
|
357
|
destination / 'mathjax_2.7.9+dfsg.orig.tar.xz'
|
358
|
]
|
359
|
|
360
|
assert {*destination.glob('*')} == {libjs_mathjax_path, *source_paths}
|
361
|
|
362
|
local_apt.download_apt_packages(sources_list, local_apt.default_keys,
|
363
|
['libjs-mathjax'], destination,
|
364
|
with_deps=True)
|
365
|
|
366
|
assert {*destination.glob('*')} == \
|
367
|
{libjs_mathjax_path, fonts_mathjax_path, *source_paths}
|
368
|
|
369
|
@pytest.mark.subprocess_run({'install_return_value': 1})
|
370
|
def test_local_apt_install_failing(mock_cache_dir, mock_apt_get_update,
|
371
|
mock_gnupg_import):
|
372
|
"""
|
373
|
Verify that the download_apt_packages() function raises a proper error when
|
374
|
'apt-get install' command returns non-0.
|
375
|
"""
|
376
|
sources_list = local_apt.SourcesList(['deb-src sth', 'deb sth'])
|
377
|
destination = mock_cache_dir / 'destination'
|
378
|
destination.mkdir()
|
379
|
|
380
|
with pytest.raises(local_apt.AptError) as excinfo:
|
381
|
local_apt.download_apt_packages(sources_list, local_apt.default_keys,
|
382
|
['libjs-mathjax'], destination,
|
383
|
with_deps=True)
|
384
|
|
385
|
assert len(excinfo.value.args) == 1
|
386
|
|
387
|
assert re.match(r'^.*\n\n.*\n\n', excinfo.value.args[0])
|
388
|
assert re.search(r'\n\nsome error output$', excinfo.value.args[0])
|
389
|
assert sample_install_stdout in excinfo.value.args[0]
|
390
|
|
391
|
assert [*destination.glob('*')] == []
|
392
|
|
393
|
@pytest.mark.subprocess_run({'download_return_value': 1})
|
394
|
def test_local_apt_download_failing(mock_cache_dir, mock_apt_get_update,
|
395
|
mock_gnupg_import):
|
396
|
"""
|
397
|
Verify that the download_apt_packages() function raises a proper error when
|
398
|
'apt-get download' command returns non-0.
|
399
|
"""
|
400
|
sources_list = local_apt.SourcesList(['deb-src sth', 'deb sth'])
|
401
|
destination = mock_cache_dir / 'destination'
|
402
|
destination.mkdir()
|
403
|
|
404
|
with pytest.raises(local_apt.AptError) as excinfo:
|
405
|
local_apt.download_apt_packages(sources_list, local_apt.default_keys,
|
406
|
['libjs-mathjax'], destination)
|
407
|
|
408
|
assert len(excinfo.value.args) == 1
|
409
|
|
410
|
assert re.match(r'.*\n\n.*\n\nsome output\n\n.*\n\nsome error output',
|
411
|
excinfo.value.args[0])
|
412
|
|
413
|
assert [*destination.glob('*')] == []
|
414
|
|
415
|
@pytest.mark.subprocess_run({'source_return_value': 1})
|
416
|
def test_local_apt_source_failing(mock_cache_dir, mock_apt_get_update,
|
417
|
mock_gnupg_import):
|
418
|
"""
|
419
|
Verify that the download_apt_packages() function raises a proper error when
|
420
|
'apt-get source' command returns non-0.
|
421
|
"""
|
422
|
sources_list = local_apt.SourcesList(['deb-src sth', 'deb sth'])
|
423
|
destination = mock_cache_dir / 'destination'
|
424
|
destination.mkdir()
|
425
|
|
426
|
with pytest.raises(local_apt.AptError) as excinfo:
|
427
|
local_apt.download_apt_packages(sources_list, local_apt.default_keys,
|
428
|
['libjs-mathjax'], destination)
|
429
|
|
430
|
assert len(excinfo.value.args) == 1
|
431
|
|
432
|
assert re.match(r'.*\n\n.*\n\nsome output\n\n.*\n\nsome error output',
|
433
|
excinfo.value.args[0])
|
434
|
|
435
|
assert [*destination.glob('*')] == []
|
436
|
|
437
|
def test_sources_list():
|
438
|
list = local_apt.SourcesList([], 'nabia')
|
439
|
assert list.identity() == 'nabia'
|
440
|
|
441
|
with pytest.raises(local_apt.DistroError):
|
442
|
local_apt.SourcesList([], 'nabiał')
|
443
|
|
444
|
list = local_apt.SourcesList(['deb sth', 'deb-src sth'], 'nabia')
|
445
|
assert list.identity() == \
|
446
|
'ef28d408b96046eae45c8ab3094ce69b2ac0c02a887e796b1d3d1a4f06fb49f1'
|