Project

General

Profile

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

hydrilla-builder / tests / test_local_apt.py @ 25ff1c9d

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'
(4-4/4)