Project

General

Profile

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

hydrilla-builder / src / test / test_hydrilla_builder.py @ 456ad6c0

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': ['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'
302

    
303
    for fn in settings.dist_filenames:
304
        dist_file_path = file_dir / f'sha256-{settings.sha256_hashes[fn]}'
305
        assert dist_file_path.is_file()
306

    
307
        with open(dist_file_path, 'rb') as file_handle:
308
            assert file_handle.read() == settings.contents[fn]
309

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

    
313
    spdx_report_sha256 = None
314

    
315
    for path in file_dir.iterdir():
316
        assert path.name.startswith('sha256-')
317
        if path.name[7:] in sha256_hashes_set:
318
            continue
319

    
320
        assert spdx_report_sha256 is None and settings.report_spdx_included
321

    
322
        with open(path, 'rt') as file_handle:
323
            spdx_contents = file_handle.read()
324

    
325
        spdx_report_sha256 = sha256(spdx_contents.encode()).digest().hex()
326
        assert spdx_report_sha256 == path.name[7:]
327

    
328
        for fn in settings.src_filenames:
329
            if not any([n in fn.lower() for n in ('license', 'reuse')]):
330
                assert settings.sha1_hashes[fn]
331

    
332
    if settings.report_spdx_included:
333
        assert spdx_report_sha256
334
        for obj in settings.expected():
335
            for file_ref in obj['source_copyright']:
336
                if file_ref['file'] == 'report.spdx':
337
                    file_ref['sha256'] = spdx_report_sha256
338

    
339
    # Verify files under 'resource/'
340
    resource_dir = dstdir / 'resource'
341

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

    
345
    for resource_json in settings.expected_resources:
346
        subdir = resource_dir / resource_json['identifier']
347
        assert ['2021.11.10'] == [path.name for path in subdir.iterdir()]
348

    
349
        with open(subdir / '2021.11.10', 'rt') as file_handle:
350
            assert json.load(file_handle) == resource_json
351

    
352
        hydrilla_util.validator_for('api_resource_description-1.schema.json')\
353
                     .validate(resource_json)
354

    
355
    # Verify files under 'mapping/'
356
    mapping_dir = dstdir / 'mapping'
357
    assert ['helloapple'] == [path.name for path in mapping_dir.iterdir()]
358

    
359
    subdir = mapping_dir / 'helloapple'
360
    assert ['2021.11.10'] == [path.name for path in subdir.iterdir()]
361

    
362
    with open(subdir / '2021.11.10', 'rt') as file_handle:
363
        assert json.load(file_handle) == settings.expected_mapping
364

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

    
368
    # Verify files under 'source/'
369
    source_dir = dstdir / 'source'
370
    assert {'hello.json', 'hello.zip'} == \
371
        set([path.name for path in source_dir.iterdir()])
372

    
373
    zip_filenames = [f'hello/{fn}' for fn in settings.src_filenames]
374

    
375
    with ZipFile(source_dir / 'hello.zip', 'r') as archive:
376
        assert set([f.filename for f in archive.filelist]) == set(zip_filenames)
377

    
378
        for zip_fn, src_fn in zip(zip_filenames, settings.src_filenames):
379
            with archive.open(zip_fn, 'r') as zip_file_handle:
380
                assert zip_file_handle.read() == settings.contents[src_fn]
381

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

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

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

    
392
def modify_index_missing_file(dummy: CaseSettings, obj: dict) -> None:
393
    """
394
    Modify index.json to expect missing report.spdx file and cause an error.
395
    """
396
    del obj['reuse_generate_spdx_report']
397

    
398
def modify_index_schema_error(dummy: CaseSettings, obj: dict) -> None:
399
    """Modify index.json to be incompliant with the schema."""
400
    del obj['definitions']
401

    
402
def modify_index_bad_comment(dummy: CaseSettings, obj: dict) -> str:
403
    """Modify index.json to have an invalid '/' in it."""
404
    return json.dumps(obj) + '/something\n'
405

    
406
def modify_index_bad_json(dummy: CaseSettings, obj: dict) -> str:
407
    """Modify index.json to not be valid json even after comment stripping."""
408
    return json.dumps(obj) + '???/\n'
409

    
410
def modify_index_missing_license(settings: CaseSettings, obj: dict) -> None:
411
    """Remove a file to make package REUSE-incompliant."""
412
    (settings.srcdir / 'README.txt.license').unlink()
413

    
414
def modify_index_file_outside(dummy: CaseSettings, obj: dict) -> None:
415
    """Make index.json illegally reference a file outside srcdir."""
416
    obj['copyright'].append({'file': '../abc'})
417

    
418
def modify_index_reference_itself(dummy: CaseSettings, obj: dict) -> None:
419
    """Make index.json illegally reference index.json."""
420
    obj['copyright'].append({'file': 'index.json'})
421

    
422
def modify_index_report_excluded(dummy: CaseSettings, obj: dict) -> None:
423
    """
424
    Make index.json require generation of index.json but not include it among
425
    copyright files.
426
    """
427
    obj['copyright'] = [fr for fr in obj['copyright']
428
                        if fr['file'] != 'report.spdx']
429

    
430
@pytest.mark.parametrize('break_index_json', [
431
    (modify_index_missing_file,     FileNotFoundError),
432
    (modify_index_schema_error,     ValidationError),
433
    (modify_index_bad_comment,      json.JSONDecodeError),
434
    (modify_index_bad_json,         json.JSONDecodeError),
435
    (modify_index_missing_license,  build.ReuseError),
436
    (modify_index_file_outside,     build.FileReferenceError),
437
    (modify_index_reference_itself, build.FileReferenceError),
438
    (modify_index_report_excluded,  build.FileReferenceError)
439
])
440
def test_build_error(tmpdir: str, break_index_json: tuple[ModifyCb, type]):
441
    """Build the sample source package and verify the produced files."""
442
    dstdir = Path(tmpdir) / 'dstdir'
443
    tmpdir = Path(tmpdir) / 'example'
444

    
445
    dstdir.mkdir(exist_ok=True)
446
    tmpdir.mkdir(exist_ok=True)
447

    
448
    modify_cb, error_type = break_index_json
449

    
450
    settings = prepare_modified(tmpdir, modify_cb)
451

    
452
    with pytest.raises(error_type):
453
        build.Build(settings.srcdir, settings.index_json_path)\
454
            .write_package_files(dstdir)
(2-2/2)