Project

General

Profile

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

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

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
import pytest
8
import json
9
import shutil
10

    
11
from tempfile import TemporaryDirectory
12
from pathlib import Path
13
from hashlib import sha256, sha1
14
from zipfile import ZipFile
15
from typing import Callable, Optional, Iterable
16

    
17
from jsonschema import ValidationError
18

    
19
from hydrilla import util as hydrilla_util
20
from hydrilla.builder import build
21

    
22
here = Path(__file__).resolve().parent
23

    
24
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
            'dependencies': [{'identifier': 'hello-message'}],
77
            '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
                'long_name': 'Hello Apple',
152
                'version': [2021, 11, 10],
153
            }, {
154
                'type':       'resource',
155
                'identifier': 'hello-message',
156
                'long_name': 'Hello Message',
157
                'version':     [2021, 11, 10],
158
            }, {
159
                'type': 'mapping',
160
                'identifier': 'helloapple',
161
	        'long_name': 'Hello Apple',
162
                '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
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
        obj = json.loads(hydrilla_util.strip_json_comments(file_handle.read()))
195

    
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
@pytest.fixture()
217
def tmpdir() -> Iterable[str]:
218
    with TemporaryDirectory() as tmpdir:
219
        yield tmpdir
220

    
221
def prepare_default(tmpdir: Path) -> CaseSettings:
222
    """Use sample source package directory as exists in VCS."""
223
    return CaseSettings()
224

    
225
def modify_index_good(settings: CaseSettings, obj: dict) -> None:
226
    """
227
    Modify index.json object to make a slightly different but *also correct* one
228
    that can be used to test some different cases.
229
    """
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
    prepare_default,
280
    lambda tmpdir: prepare_modified(tmpdir, modify_index_good)
281
])
282
def test_build(tmpdir, prepare_source_example):
283
    """Build the sample source package and verify the produced files."""
284
    # First, build the package
285
    dstdir = Path(tmpdir) / 'dstdir'
286
    tmpdir = Path(tmpdir) / 'example'
287

    
288
    dstdir.mkdir(exist_ok=True)
289
    tmpdir.mkdir(exist_ok=True)
290

    
291
    settings = prepare_source_example(tmpdir)
292

    
293
    build.Build(settings.srcdir, settings.index_json_path)\
294
        .write_package_files(dstdir)
295

    
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
    file_dir = dstdir / 'file' / 'sha256'
302

    
303
    for fn in settings.dist_filenames:
304
        dist_file_path = file_dir / settings.sha256_hashes[fn]
305
        assert dist_file_path.is_file()
306

    
307
        assert dist_file_path.read_bytes() == settings.contents[fn]
308

    
309
    sha256_hashes_set = set([settings.sha256_hashes[fn]
310
                             for fn in settings.dist_filenames])
311

    
312
    spdx_report_sha256 = None
313

    
314
    for path in file_dir.iterdir():
315
        if path.name in sha256_hashes_set:
316
            continue
317

    
318
        assert spdx_report_sha256 is None and settings.report_spdx_included
319

    
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
        assert spdx_report_sha256 == path.name
325

    
326
        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

    
337
    # Verify files under 'resource/'
338
    resource_dir = dstdir / 'resource'
339

    
340
    assert set([rj['identifier'] for rj in settings.expected_resources]) == \
341
        set([path.name for path in resource_dir.iterdir()])
342

    
343
    for resource_json in settings.expected_resources:
344
        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
        hydrilla_util.validator_for('api_resource_description-1.schema.json')\
351
                     .validate(resource_json)
352

    
353
    # 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
        assert json.load(file_handle) == settings.expected_mapping
362

    
363
    hydrilla_util.validator_for('api_mapping_description-1.schema.json')\
364
                 .validate(settings.expected_mapping)
365

    
366
    # 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
    zip_filenames = [f'hello/{fn}' for fn in settings.src_filenames]
372

    
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
        for zip_fn, src_fn in zip(zip_filenames, settings.src_filenames):
377
            with archive.open(zip_fn, 'r') as zip_file_handle:
378
                assert zip_file_handle.read() == settings.contents[src_fn]
379

    
380
    zip_ref = settings.expected_source_description['source_archives']['zip']
381
    with open(source_dir / 'hello.zip', 'rb') as file_handle:
382
        zip_ref['sha256'] = sha256(file_handle.read()).digest().hex()
383

    
384
    with open(source_dir / 'hello.json', 'rt') as file_handle:
385
        assert json.load(file_handle) == settings.expected_source_description
386

    
387
    hydrilla_util.validator_for('api_source_description-1.schema.json')\
388
                 .validate(settings.expected_source_description)
389

    
390
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
            .write_package_files(dstdir)
(2-2/2)