Project

General

Profile

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

hydrilla-builder / src / test / test_hydrilla_builder.py @ d49925b8

1 5ac7ec33 Wojtek Kosior
# 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
import pytest
8 34072d8d Wojtek Kosior
import json
9 b5eb89e1 Wojtek Kosior
import shutil
10 5ac7ec33 Wojtek Kosior
11
from tempfile import TemporaryDirectory
12
from pathlib import Path
13 34072d8d Wojtek Kosior
from hashlib import sha256, sha1
14
from zipfile import ZipFile
15 8c65ebbd Wojtek Kosior
from typing import Callable, Optional, Iterable
16 b5eb89e1 Wojtek Kosior
17
from jsonschema import ValidationError
18
19 16eaeb86 Wojtek Kosior
from hydrilla import util as hydrilla_util
20
from hydrilla.builder import build
21 5ac7ec33 Wojtek Kosior
22
here = Path(__file__).resolve().parent
23
24 8a036bc7 Wojtek Kosior
default_srcdir = here / 'source-package-example'
25
26
default_js_filenames = ['bye.js', 'hello.js', 'message.js']
27
default_dist_filenames = [*default_js_filenames, 'LICENSES/CC0-1.0.txt']
28
default_src_filenames = [
29
    *default_dist_filenames,
30
    'README.txt', 'README.txt.license', '.reuse/dep5', 'index.json'
31
]
32
33
default_sha1_hashes   = {}
34
default_sha256_hashes = {}
35
default_contents      = {}
36
37
for fn in default_src_filenames:
38
    with open(default_srcdir / fn, 'rb') as file_handle:
39
        default_contents[fn]      = file_handle.read()
40
        default_sha256_hashes[fn] = sha256(default_contents[fn]).digest().hex()
41
        default_sha1_hashes[fn]   = sha1(default_contents[fn]).digest().hex()
42
43
class CaseSettings:
44
    """Gather parametrized values in a class."""
45
    def __init__(self):
46
        """Init CaseSettings with default values."""
47
        self.srcdir = default_srcdir
48
        self.index_json_path = Path('index.json')
49
        self.report_spdx_included = True
50
51
        self.js_filenames   = default_js_filenames.copy()
52
        self.dist_filenames = default_dist_filenames.copy()
53
        self.src_filenames  = default_src_filenames.copy()
54
55
        self.sha1_hashes   = default_sha1_hashes.copy()
56
        self.sha256_hashes = default_sha256_hashes.copy()
57
        self.contents      = default_contents.copy()
58
59
        self.expected_resources = [{
60
            'api_schema_version': [1, 0, 1],
61
            'source_name': 'hello',
62
            'source_copyright': [{
63
                'file': 'report.spdx',
64
                'sha256': '!!!!value to fill during test!!!!'
65
            }, {
66
                'file': 'LICENSES/CC0-1.0.txt',
67
                'sha256': self.sha256_hashes['LICENSES/CC0-1.0.txt']
68
            }],
69
            'type': 'resource',
70
            'identifier': 'helloapple',
71
            'long_name': 'Hello Apple',
72
            'uuid': 'a6754dcb-58d8-4b7a-a245-24fd7ad4cd68',
73
            'version': [2021, 11, 10],
74
            'revision': 1,
75
            'description': 'greets an apple',
76 d49925b8 Wojtek Kosior
            'dependencies': [{'identifier': 'hello-message'}],
77 8a036bc7 Wojtek Kosior
            'scripts': [{
78
                'file': 'hello.js',
79
                'sha256': self.sha256_hashes['hello.js']
80
            }, {
81
                'file': 'bye.js',
82
                'sha256': self.sha256_hashes['bye.js']
83
            }]
84
        }, {
85
            'api_schema_version': [1, 0, 1],
86
            'source_name': 'hello',
87
            'source_copyright': [{
88
                'file': 'report.spdx',
89
                'sha256': '!!!!value to fill during test!!!!'
90
            }, {
91
                'file': 'LICENSES/CC0-1.0.txt',
92
                'sha256': self.sha256_hashes['LICENSES/CC0-1.0.txt']
93
            }],
94
            'type': 'resource',
95
            'identifier': 'hello-message',
96
            'long_name': 'Hello Message',
97
            'uuid': '1ec36229-298c-4b35-8105-c4f2e1b9811e',
98
            'version': [2021, 11, 10],
99
            'revision': 2,
100
            'description': 'define messages for saying hello and bye',
101
            'dependencies': [],
102
            'scripts': [{
103
                'file': 'message.js',
104
                'sha256': self.sha256_hashes['message.js']
105
            }]
106
        }]
107
        self.expected_mapping = {
108
            'api_schema_version': [1, 0, 1],
109
            'source_name': 'hello',
110
            'source_copyright': [{
111
                'file': 'report.spdx',
112
                'sha256': '!!!!value to fill during test!!!!'
113
            }, {
114
                'file': 'LICENSES/CC0-1.0.txt',
115
                'sha256': self.sha256_hashes['LICENSES/CC0-1.0.txt']
116
            }],
117
            'type': 'mapping',
118
	    'identifier': 'helloapple',
119
	    'long_name': 'Hello Apple',
120
	    'uuid': '54d23bba-472e-42f5-9194-eaa24c0e3ee7',
121
	    'version': [2021, 11, 10],
122
	    'description': 'causes apple to get greeted on Hydrillabugs issue tracker',
123
	    'payloads': {
124
	        'https://hydrillabugs.koszko.org/***': {
125
		    'identifier': 'helloapple'
126
	        },
127
	        'https://hachettebugs.koszko.org/***': {
128
		    'identifier': 'helloapple'
129
                }
130
            }
131
        }
132
        self.expected_source_description = {
133
            'api_schema_version': [1, 0, 1],
134
            'source_name': 'hello',
135
            'source_copyright': [{
136
                'file': 'report.spdx',
137
                'sha256': '!!!!value to fill during test!!!!'
138
            }, {
139
                'file': 'LICENSES/CC0-1.0.txt',
140
                'sha256': self.sha256_hashes['LICENSES/CC0-1.0.txt']
141
            }],
142
            'source_archives': {
143
                'zip': {
144
                    'sha256': '!!!!value to fill during test!!!!',
145
                }
146
            },
147
            'upstream_url': 'https://git.koszko.org/hydrilla-source-package-example',
148
            'definitions': [{
149
                'type': 'resource',
150
                'identifier': 'helloapple',
151 456ad6c0 Wojtek Kosior
                'long_name': 'Hello Apple',
152 8a036bc7 Wojtek Kosior
                'version': [2021, 11, 10],
153
            }, {
154
                'type':       'resource',
155
                'identifier': 'hello-message',
156 456ad6c0 Wojtek Kosior
                'long_name': 'Hello Message',
157 8a036bc7 Wojtek Kosior
                'version':     [2021, 11, 10],
158
            }, {
159
                'type': 'mapping',
160
                'identifier': 'helloapple',
161 456ad6c0 Wojtek Kosior
	        'long_name': 'Hello Apple',
162 8a036bc7 Wojtek Kosior
                'version': [2021, 11, 10],
163
            }]
164
        }
165
166
    def expected(self) -> list[dict]:
167
        """
168
        Convenience method to get a list of expected jsons of 2 resources,
169
        1 mapping and 1 source description we have.
170
        """
171
        return [
172
            *self.expected_resources,
173
            self.expected_mapping,
174
            self.expected_source_description
175
        ]
176
177 b5eb89e1 Wojtek Kosior
ModifyCb = Callable[[CaseSettings, dict], Optional[str]]
178
179
def prepare_modified(tmpdir: Path, modify_cb: ModifyCb) -> CaseSettings:
180
    """
181
    Use sample source package directory with an alternative, modified
182
    index.json.
183
    """
184
    settings = CaseSettings()
185
186
    for fn in settings.src_filenames:
187
        copy_path = tmpdir / 'srcdir_copy' / fn
188
        copy_path.parent.mkdir(parents=True, exist_ok=True)
189
        shutil.copy(settings.srcdir / fn, copy_path)
190
191
    settings.srcdir = tmpdir / 'srcdir_copy'
192
193
    with open(settings.srcdir / 'index.json', 'rt') as file_handle:
194 16eaeb86 Wojtek Kosior
        obj = json.loads(hydrilla_util.strip_json_comments(file_handle.read()))
195 b5eb89e1 Wojtek Kosior
196
    contents = modify_cb(settings, obj)
197
198
    # Replace the other index.json with new one
199
    settings.index_json_path = tmpdir / 'replacement.json'
200
201
    if contents is None:
202
        contents = json.dumps(obj)
203
204
    contents = contents.encode()
205
206
    settings.contents['index.json'] = contents
207
208
    settings.sha256_hashes['index.json'] = sha256(contents).digest().hex()
209
    settings.sha1_hashes['index.json']   = sha1(contents).digest().hex()
210
211
    with open(settings.index_json_path, 'wb') as file_handle:
212
        file_handle.write(contents)
213
214
    return settings
215
216 5ac7ec33 Wojtek Kosior
@pytest.fixture()
217 8c65ebbd Wojtek Kosior
def tmpdir() -> Iterable[str]:
218 5ac7ec33 Wojtek Kosior
    with TemporaryDirectory() as tmpdir:
219
        yield tmpdir
220
221 8a036bc7 Wojtek Kosior
def prepare_default(tmpdir: Path) -> CaseSettings:
222
    """Use sample source package directory as exists in VCS."""
223
    return CaseSettings()
224
225 b5eb89e1 Wojtek Kosior
def modify_index_good(settings: CaseSettings, obj: dict) -> None:
226 8a036bc7 Wojtek Kosior
    """
227 b5eb89e1 Wojtek Kosior
    Modify index.json object to make a slightly different but *also correct* one
228
    that can be used to test some different cases.
229 8a036bc7 Wojtek Kosior
    """
230
    # Add comments that should be preserved.
231
    for dictionary in (obj, settings.expected_source_description):
232
        dictionary['comment'] = 'index_json comment'
233
234
    for i, dicts in enumerate(zip(obj['definitions'], settings.expected())):
235
        for dictionary in dicts:
236
            dictionary['comment'] = f'item {i}'
237
238
    # Remove spdx report generation
239
    del obj['reuse_generate_spdx_report']
240
    obj['copyright'].remove({'file': 'report.spdx'})
241
242
    settings.report_spdx_included = False
243
244
    for json_description in settings.expected():
245
        json_description['source_copyright'] = \
246
            [fr for fr in json_description['source_copyright']
247
             if fr['file'] != 'report.spdx']
248
249
    # Use default value ([]) for 'additionall_files' property
250
    del obj['additional_files']
251
252
    settings.src_filenames = [*settings.dist_filenames, 'index.json']
253
254
    # Use default value ([]) for 'scripts' property in one of the resources
255
    del obj['definitions'][1]['scripts']
256
257
    settings.expected_resources[1]['scripts'] = []
258
259
    for prefix in ('js', 'dist', 'src'):
260
        getattr(settings, f'{prefix}_filenames').remove('message.js')
261
262
    # Use default value ({}) for 'pyloads' property in mapping
263
    del obj['definitions'][2]['payloads']
264
265
    settings.expected_mapping['payloads'] = {}
266
267
    # Add some unrecognized properties that should be stripped
268
    to_process = [obj]
269
    while to_process:
270
        processed = to_process.pop()
271
272
        if type(processed) is list:
273
            to_process.extend(processed)
274
        elif type(processed) is dict and 'spurious_property' not in processed:
275
            to_process.extend(processed.values())
276
            processed['spurious_property'] = 'some value'
277
278
@pytest.mark.parametrize('prepare_source_example', [
279 b5eb89e1 Wojtek Kosior
    prepare_default,
280
    lambda tmpdir: prepare_modified(tmpdir, modify_index_good)
281 8a036bc7 Wojtek Kosior
])
282
def test_build(tmpdir, prepare_source_example):
283 5ac7ec33 Wojtek Kosior
    """Build the sample source package and verify the produced files."""
284 34072d8d Wojtek Kosior
    # First, build the package
285 8a036bc7 Wojtek Kosior
    dstdir = Path(tmpdir) / 'dstdir'
286
    tmpdir = Path(tmpdir) / 'example'
287
288
    dstdir.mkdir(exist_ok=True)
289
    tmpdir.mkdir(exist_ok=True)
290 5ac7ec33 Wojtek Kosior
291 8a036bc7 Wojtek Kosior
    settings = prepare_source_example(tmpdir)
292
293 b5eb89e1 Wojtek Kosior
    build.Build(settings.srcdir, settings.index_json_path)\
294
        .write_package_files(dstdir)
295 34072d8d Wojtek Kosior
296
    # Verify directories under destination directory
297
    assert {'file', 'resource', 'mapping', 'source'} == \
298
        set([path.name for path in dstdir.iterdir()])
299
300
    # Verify files under 'file/'
301 fd38a0e1 Wojtek Kosior
    file_dir = dstdir / 'file' / 'sha256'
302 34072d8d Wojtek Kosior
303 8a036bc7 Wojtek Kosior
    for fn in settings.dist_filenames:
304 fd38a0e1 Wojtek Kosior
        dist_file_path = file_dir / settings.sha256_hashes[fn]
305 34072d8d Wojtek Kosior
        assert dist_file_path.is_file()
306
307 fd38a0e1 Wojtek Kosior
        assert dist_file_path.read_bytes() == settings.contents[fn]
308 8a036bc7 Wojtek Kosior
309
    sha256_hashes_set = set([settings.sha256_hashes[fn]
310
                             for fn in settings.dist_filenames])
311 34072d8d Wojtek Kosior
312
    spdx_report_sha256 = None
313
314
    for path in file_dir.iterdir():
315 fd38a0e1 Wojtek Kosior
        if path.name in sha256_hashes_set:
316 34072d8d Wojtek Kosior
            continue
317
318 8a036bc7 Wojtek Kosior
        assert spdx_report_sha256 is None and settings.report_spdx_included
319 34072d8d Wojtek Kosior
320
        with open(path, 'rt') as file_handle:
321
            spdx_contents = file_handle.read()
322
323
        spdx_report_sha256 = sha256(spdx_contents.encode()).digest().hex()
324 fd38a0e1 Wojtek Kosior
        assert spdx_report_sha256 == path.name
325 34072d8d Wojtek Kosior
326 8a036bc7 Wojtek Kosior
        for fn in settings.src_filenames:
327
            if not any([n in fn.lower() for n in ('license', 'reuse')]):
328
                assert settings.sha1_hashes[fn]
329
330
    if settings.report_spdx_included:
331
        assert spdx_report_sha256
332
        for obj in settings.expected():
333
            for file_ref in obj['source_copyright']:
334
                if file_ref['file'] == 'report.spdx':
335
                    file_ref['sha256'] = spdx_report_sha256
336 34072d8d Wojtek Kosior
337
    # Verify files under 'resource/'
338
    resource_dir = dstdir / 'resource'
339
340 8a036bc7 Wojtek Kosior
    assert set([rj['identifier'] for rj in settings.expected_resources]) == \
341 34072d8d Wojtek Kosior
        set([path.name for path in resource_dir.iterdir()])
342
343 8a036bc7 Wojtek Kosior
    for resource_json in settings.expected_resources:
344 34072d8d Wojtek Kosior
        subdir = resource_dir / resource_json['identifier']
345
        assert ['2021.11.10'] == [path.name for path in subdir.iterdir()]
346
347
        with open(subdir / '2021.11.10', 'rt') as file_handle:
348
            assert json.load(file_handle) == resource_json
349
350 456ad6c0 Wojtek Kosior
        hydrilla_util.validator_for('api_resource_description-1.schema.json')\
351
                     .validate(resource_json)
352
353 34072d8d Wojtek Kosior
    # Verify files under 'mapping/'
354
    mapping_dir = dstdir / 'mapping'
355
    assert ['helloapple'] == [path.name for path in mapping_dir.iterdir()]
356
357
    subdir = mapping_dir / 'helloapple'
358
    assert ['2021.11.10'] == [path.name for path in subdir.iterdir()]
359
360
    with open(subdir / '2021.11.10', 'rt') as file_handle:
361 8a036bc7 Wojtek Kosior
        assert json.load(file_handle) == settings.expected_mapping
362 34072d8d Wojtek Kosior
363 456ad6c0 Wojtek Kosior
    hydrilla_util.validator_for('api_mapping_description-1.schema.json')\
364
                 .validate(settings.expected_mapping)
365
366 34072d8d Wojtek Kosior
    # Verify files under 'source/'
367
    source_dir = dstdir / 'source'
368
    assert {'hello.json', 'hello.zip'} == \
369
        set([path.name for path in source_dir.iterdir()])
370
371 8a036bc7 Wojtek Kosior
    zip_filenames = [f'hello/{fn}' for fn in settings.src_filenames]
372 34072d8d Wojtek Kosior
373
    with ZipFile(source_dir / 'hello.zip', 'r') as archive:
374
        assert set([f.filename for f in archive.filelist]) == set(zip_filenames)
375
376 8a036bc7 Wojtek Kosior
        for zip_fn, src_fn in zip(zip_filenames, settings.src_filenames):
377 34072d8d Wojtek Kosior
            with archive.open(zip_fn, 'r') as zip_file_handle:
378 8a036bc7 Wojtek Kosior
                assert zip_file_handle.read() == settings.contents[src_fn]
379 34072d8d Wojtek Kosior
380 8a036bc7 Wojtek Kosior
    zip_ref = settings.expected_source_description['source_archives']['zip']
381 34072d8d Wojtek Kosior
    with open(source_dir / 'hello.zip', 'rb') as file_handle:
382 8a036bc7 Wojtek Kosior
        zip_ref['sha256'] = sha256(file_handle.read()).digest().hex()
383 34072d8d Wojtek Kosior
384
    with open(source_dir / 'hello.json', 'rt') as file_handle:
385 8a036bc7 Wojtek Kosior
        assert json.load(file_handle) == settings.expected_source_description
386 34072d8d Wojtek Kosior
387 456ad6c0 Wojtek Kosior
    hydrilla_util.validator_for('api_source_description-1.schema.json')\
388
                 .validate(settings.expected_source_description)
389
390 b5eb89e1 Wojtek Kosior
def modify_index_missing_file(dummy: CaseSettings, obj: dict) -> None:
391
    """
392
    Modify index.json to expect missing report.spdx file and cause an error.
393
    """
394
    del obj['reuse_generate_spdx_report']
395
396
def modify_index_schema_error(dummy: CaseSettings, obj: dict) -> None:
397
    """Modify index.json to be incompliant with the schema."""
398
    del obj['definitions']
399
400
def modify_index_bad_comment(dummy: CaseSettings, obj: dict) -> str:
401
    """Modify index.json to have an invalid '/' in it."""
402
    return json.dumps(obj) + '/something\n'
403
404
def modify_index_bad_json(dummy: CaseSettings, obj: dict) -> str:
405
    """Modify index.json to not be valid json even after comment stripping."""
406
    return json.dumps(obj) + '???/\n'
407
408
def modify_index_missing_license(settings: CaseSettings, obj: dict) -> None:
409
    """Remove a file to make package REUSE-incompliant."""
410
    (settings.srcdir / 'README.txt.license').unlink()
411
412
def modify_index_file_outside(dummy: CaseSettings, obj: dict) -> None:
413
    """Make index.json illegally reference a file outside srcdir."""
414
    obj['copyright'].append({'file': '../abc'})
415
416
def modify_index_reference_itself(dummy: CaseSettings, obj: dict) -> None:
417
    """Make index.json illegally reference index.json."""
418
    obj['copyright'].append({'file': 'index.json'})
419
420
def modify_index_report_excluded(dummy: CaseSettings, obj: dict) -> None:
421
    """
422
    Make index.json require generation of index.json but not include it among
423
    copyright files.
424
    """
425
    obj['copyright'] = [fr for fr in obj['copyright']
426
                        if fr['file'] != 'report.spdx']
427
428
@pytest.mark.parametrize('break_index_json', [
429
    (modify_index_missing_file,     FileNotFoundError),
430
    (modify_index_schema_error,     ValidationError),
431
    (modify_index_bad_comment,      json.JSONDecodeError),
432
    (modify_index_bad_json,         json.JSONDecodeError),
433
    (modify_index_missing_license,  build.ReuseError),
434
    (modify_index_file_outside,     build.FileReferenceError),
435
    (modify_index_reference_itself, build.FileReferenceError),
436
    (modify_index_report_excluded,  build.FileReferenceError)
437
])
438
def test_build_error(tmpdir: str, break_index_json: tuple[ModifyCb, type]):
439
    """Build the sample source package and verify the produced files."""
440
    dstdir = Path(tmpdir) / 'dstdir'
441
    tmpdir = Path(tmpdir) / 'example'
442
443
    dstdir.mkdir(exist_ok=True)
444
    tmpdir.mkdir(exist_ok=True)
445
446
    modify_cb, error_type = break_index_json
447
448
    settings = prepare_modified(tmpdir, modify_cb)
449
450
    with pytest.raises(error_type):
451
        build.Build(settings.srcdir, settings.index_json_path)\
452 16eaeb86 Wojtek Kosior
            .write_package_files(dstdir)