Project

General

Profile

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

hydrilla-builder / src / test / test_hydrilla_builder.py @ 8c65ebbd

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
                'version': [2021, 11, 10],
152
            }, {
153
                'type':       'resource',
154
                'identifier': 'hello-message',
155
                'version':     [2021, 11, 10],
156
            }, {
157
                'type': 'mapping',
158
                'identifier': 'helloapple',
159
                'version': [2021, 11, 10],
160
            }]
161
        }
162

    
163
    def expected(self) -> list[dict]:
164
        """
165
        Convenience method to get a list of expected jsons of 2 resources,
166
        1 mapping and 1 source description we have.
167
        """
168
        return [
169
            *self.expected_resources,
170
            self.expected_mapping,
171
            self.expected_source_description
172
        ]
173

    
174
ModifyCb = Callable[[CaseSettings, dict], Optional[str]]
175

    
176
def prepare_modified(tmpdir: Path, modify_cb: ModifyCb) -> CaseSettings:
177
    """
178
    Use sample source package directory with an alternative, modified
179
    index.json.
180
    """
181
    settings = CaseSettings()
182

    
183
    for fn in settings.src_filenames:
184
        copy_path = tmpdir / 'srcdir_copy' / fn
185
        copy_path.parent.mkdir(parents=True, exist_ok=True)
186
        shutil.copy(settings.srcdir / fn, copy_path)
187

    
188
    settings.srcdir = tmpdir / 'srcdir_copy'
189

    
190
    with open(settings.srcdir / 'index.json', 'rt') as file_handle:
191
        obj = json.loads(hydrilla_util.strip_json_comments(file_handle.read()))
192

    
193
    contents = modify_cb(settings, obj)
194

    
195
    # Replace the other index.json with new one
196
    settings.index_json_path = tmpdir / 'replacement.json'
197

    
198
    if contents is None:
199
        contents = json.dumps(obj)
200

    
201
    contents = contents.encode()
202

    
203
    settings.contents['index.json'] = contents
204

    
205
    settings.sha256_hashes['index.json'] = sha256(contents).digest().hex()
206
    settings.sha1_hashes['index.json']   = sha1(contents).digest().hex()
207

    
208
    with open(settings.index_json_path, 'wb') as file_handle:
209
        file_handle.write(contents)
210

    
211
    return settings
212

    
213
@pytest.fixture()
214
def tmpdir() -> Iterable[str]:
215
    with TemporaryDirectory() as tmpdir:
216
        yield tmpdir
217

    
218
def prepare_default(tmpdir: Path) -> CaseSettings:
219
    """Use sample source package directory as exists in VCS."""
220
    return CaseSettings()
221

    
222
def modify_index_good(settings: CaseSettings, obj: dict) -> None:
223
    """
224
    Modify index.json object to make a slightly different but *also correct* one
225
    that can be used to test some different cases.
226
    """
227
    # Add comments that should be preserved.
228
    for dictionary in (obj, settings.expected_source_description):
229
        dictionary['comment'] = 'index_json comment'
230

    
231
    for i, dicts in enumerate(zip(obj['definitions'], settings.expected())):
232
        for dictionary in dicts:
233
            dictionary['comment'] = f'item {i}'
234

    
235
    # Remove spdx report generation
236
    del obj['reuse_generate_spdx_report']
237
    obj['copyright'].remove({'file': 'report.spdx'})
238

    
239
    settings.report_spdx_included = False
240

    
241
    for json_description in settings.expected():
242
        json_description['source_copyright'] = \
243
            [fr for fr in json_description['source_copyright']
244
             if fr['file'] != 'report.spdx']
245

    
246
    # Use default value ([]) for 'additionall_files' property
247
    del obj['additional_files']
248

    
249
    settings.src_filenames = [*settings.dist_filenames, 'index.json']
250

    
251
    # Use default value ([]) for 'scripts' property in one of the resources
252
    del obj['definitions'][1]['scripts']
253

    
254
    settings.expected_resources[1]['scripts'] = []
255

    
256
    for prefix in ('js', 'dist', 'src'):
257
        getattr(settings, f'{prefix}_filenames').remove('message.js')
258

    
259
    # Use default value ({}) for 'pyloads' property in mapping
260
    del obj['definitions'][2]['payloads']
261

    
262
    settings.expected_mapping['payloads'] = {}
263

    
264
    # Add some unrecognized properties that should be stripped
265
    to_process = [obj]
266
    while to_process:
267
        processed = to_process.pop()
268

    
269
        if type(processed) is list:
270
            to_process.extend(processed)
271
        elif type(processed) is dict and 'spurious_property' not in processed:
272
            to_process.extend(processed.values())
273
            processed['spurious_property'] = 'some value'
274

    
275
@pytest.mark.parametrize('prepare_source_example', [
276
    prepare_default,
277
    lambda tmpdir: prepare_modified(tmpdir, modify_index_good)
278
])
279
def test_build(tmpdir, prepare_source_example):
280
    """Build the sample source package and verify the produced files."""
281
    # First, build the package
282
    dstdir = Path(tmpdir) / 'dstdir'
283
    tmpdir = Path(tmpdir) / 'example'
284

    
285
    dstdir.mkdir(exist_ok=True)
286
    tmpdir.mkdir(exist_ok=True)
287

    
288
    settings = prepare_source_example(tmpdir)
289

    
290
    build.Build(settings.srcdir, settings.index_json_path)\
291
        .write_package_files(dstdir)
292

    
293
    # Verify directories under destination directory
294
    assert {'file', 'resource', 'mapping', 'source'} == \
295
        set([path.name for path in dstdir.iterdir()])
296

    
297
    # Verify files under 'file/'
298
    file_dir = dstdir / 'file'
299

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

    
304
        with open(dist_file_path, 'rb') as file_handle:
305
            assert file_handle.read() == settings.contents[fn]
306

    
307
    sha256_hashes_set = set([settings.sha256_hashes[fn]
308
                             for fn in settings.dist_filenames])
309

    
310
    spdx_report_sha256 = None
311

    
312
    for path in file_dir.iterdir():
313
        assert path.name.startswith('sha256-')
314
        if path.name[7:] in sha256_hashes_set:
315
            continue
316

    
317
        assert spdx_report_sha256 is None and settings.report_spdx_included
318

    
319
        with open(path, 'rt') as file_handle:
320
            spdx_contents = file_handle.read()
321

    
322
        spdx_report_sha256 = sha256(spdx_contents.encode()).digest().hex()
323
        assert spdx_report_sha256 == path.name[7:]
324

    
325
        for fn in settings.src_filenames:
326
            if not any([n in fn.lower() for n in ('license', 'reuse')]):
327
                assert settings.sha1_hashes[fn]
328

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

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

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

    
342
    for resource_json in settings.expected_resources:
343
        subdir = resource_dir / resource_json['identifier']
344
        assert ['2021.11.10'] == [path.name for path in subdir.iterdir()]
345

    
346
        with open(subdir / '2021.11.10', 'rt') as file_handle:
347
            assert json.load(file_handle) == resource_json
348

    
349
    # Verify files under 'mapping/'
350
    mapping_dir = dstdir / 'mapping'
351
    assert ['helloapple'] == [path.name for path in mapping_dir.iterdir()]
352

    
353
    subdir = mapping_dir / 'helloapple'
354
    assert ['2021.11.10'] == [path.name for path in subdir.iterdir()]
355

    
356
    with open(subdir / '2021.11.10', 'rt') as file_handle:
357
        assert json.load(file_handle) == settings.expected_mapping
358

    
359
    # Verify files under 'source/'
360
    source_dir = dstdir / 'source'
361
    assert {'hello.json', 'hello.zip'} == \
362
        set([path.name for path in source_dir.iterdir()])
363

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

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

    
369
        for zip_fn, src_fn in zip(zip_filenames, settings.src_filenames):
370
            with archive.open(zip_fn, 'r') as zip_file_handle:
371
                assert zip_file_handle.read() == settings.contents[src_fn]
372

    
373
    zip_ref = settings.expected_source_description['source_archives']['zip']
374
    with open(source_dir / 'hello.zip', 'rb') as file_handle:
375
        zip_ref['sha256'] = sha256(file_handle.read()).digest().hex()
376

    
377
    with open(source_dir / 'hello.json', 'rt') as file_handle:
378
        assert json.load(file_handle) == settings.expected_source_description
379

    
380
def modify_index_missing_file(dummy: CaseSettings, obj: dict) -> None:
381
    """
382
    Modify index.json to expect missing report.spdx file and cause an error.
383
    """
384
    del obj['reuse_generate_spdx_report']
385

    
386
def modify_index_schema_error(dummy: CaseSettings, obj: dict) -> None:
387
    """Modify index.json to be incompliant with the schema."""
388
    del obj['definitions']
389

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

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

    
398
def modify_index_missing_license(settings: CaseSettings, obj: dict) -> None:
399
    """Remove a file to make package REUSE-incompliant."""
400
    (settings.srcdir / 'README.txt.license').unlink()
401

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

    
406
def modify_index_reference_itself(dummy: CaseSettings, obj: dict) -> None:
407
    """Make index.json illegally reference index.json."""
408
    obj['copyright'].append({'file': 'index.json'})
409

    
410
def modify_index_report_excluded(dummy: CaseSettings, obj: dict) -> None:
411
    """
412
    Make index.json require generation of index.json but not include it among
413
    copyright files.
414
    """
415
    obj['copyright'] = [fr for fr in obj['copyright']
416
                        if fr['file'] != 'report.spdx']
417

    
418
@pytest.mark.parametrize('break_index_json', [
419
    (modify_index_missing_file,     FileNotFoundError),
420
    (modify_index_schema_error,     ValidationError),
421
    (modify_index_bad_comment,      json.JSONDecodeError),
422
    (modify_index_bad_json,         json.JSONDecodeError),
423
    (modify_index_missing_license,  build.ReuseError),
424
    (modify_index_file_outside,     build.FileReferenceError),
425
    (modify_index_reference_itself, build.FileReferenceError),
426
    (modify_index_report_excluded,  build.FileReferenceError)
427
])
428
def test_build_error(tmpdir: str, break_index_json: tuple[ModifyCb, type]):
429
    """Build the sample source package and verify the produced files."""
430
    dstdir = Path(tmpdir) / 'dstdir'
431
    tmpdir = Path(tmpdir) / 'example'
432

    
433
    dstdir.mkdir(exist_ok=True)
434
    tmpdir.mkdir(exist_ok=True)
435

    
436
    modify_cb, error_type = break_index_json
437

    
438
    settings = prepare_modified(tmpdir, modify_cb)
439

    
440
    with pytest.raises(error_type):
441
        build.Build(settings.srcdir, settings.index_json_path)\
442
            .write_package_files(dstdir)
(2-2/2)