Project

General

Profile

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

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

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
16

    
17
from jsonschema import ValidationError
18

    
19
from hydrilla_builder import build
20

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

    
23
default_srcdir = here / 'source-package-example'
24

    
25
default_js_filenames = ['bye.js', 'hello.js', 'message.js']
26
default_dist_filenames = [*default_js_filenames, 'LICENSES/CC0-1.0.txt']
27
default_src_filenames = [
28
    *default_dist_filenames,
29
    'README.txt', 'README.txt.license', '.reuse/dep5', 'index.json'
30
]
31

    
32
default_sha1_hashes   = {}
33
default_sha256_hashes = {}
34
default_contents      = {}
35

    
36
for fn in default_src_filenames:
37
    with open(default_srcdir / fn, 'rb') as file_handle:
38
        default_contents[fn]      = file_handle.read()
39
        default_sha256_hashes[fn] = sha256(default_contents[fn]).digest().hex()
40
        default_sha1_hashes[fn]   = sha1(default_contents[fn]).digest().hex()
41

    
42
class CaseSettings:
43
    """Gather parametrized values in a class."""
44
    def __init__(self):
45
        """Init CaseSettings with default values."""
46
        self.srcdir = default_srcdir
47
        self.index_json_path = Path('index.json')
48
        self.report_spdx_included = True
49

    
50
        self.js_filenames   = default_js_filenames.copy()
51
        self.dist_filenames = default_dist_filenames.copy()
52
        self.src_filenames  = default_src_filenames.copy()
53

    
54
        self.sha1_hashes   = default_sha1_hashes.copy()
55
        self.sha256_hashes = default_sha256_hashes.copy()
56
        self.contents      = default_contents.copy()
57

    
58
        self.expected_resources = [{
59
            'api_schema_version': [1, 0, 1],
60
            'source_name': 'hello',
61
            'source_copyright': [{
62
                'file': 'report.spdx',
63
                'sha256': '!!!!value to fill during test!!!!'
64
            }, {
65
                'file': 'LICENSES/CC0-1.0.txt',
66
                'sha256': self.sha256_hashes['LICENSES/CC0-1.0.txt']
67
            }],
68
            'type': 'resource',
69
            'identifier': 'helloapple',
70
            'long_name': 'Hello Apple',
71
            'uuid': 'a6754dcb-58d8-4b7a-a245-24fd7ad4cd68',
72
            'version': [2021, 11, 10],
73
            'revision': 1,
74
            'description': 'greets an apple',
75
            'dependencies': ['hello-message'],
76
            'scripts': [{
77
                'file': 'hello.js',
78
                'sha256': self.sha256_hashes['hello.js']
79
            }, {
80
                'file': 'bye.js',
81
                'sha256': self.sha256_hashes['bye.js']
82
            }]
83
        }, {
84
            'api_schema_version': [1, 0, 1],
85
            'source_name': 'hello',
86
            'source_copyright': [{
87
                'file': 'report.spdx',
88
                'sha256': '!!!!value to fill during test!!!!'
89
            }, {
90
                'file': 'LICENSES/CC0-1.0.txt',
91
                'sha256': self.sha256_hashes['LICENSES/CC0-1.0.txt']
92
            }],
93
            'type': 'resource',
94
            'identifier': 'hello-message',
95
            'long_name': 'Hello Message',
96
            'uuid': '1ec36229-298c-4b35-8105-c4f2e1b9811e',
97
            'version': [2021, 11, 10],
98
            'revision': 2,
99
            'description': 'define messages for saying hello and bye',
100
            'dependencies': [],
101
            'scripts': [{
102
                'file': 'message.js',
103
                'sha256': self.sha256_hashes['message.js']
104
            }]
105
        }]
106
        self.expected_mapping = {
107
            'api_schema_version': [1, 0, 1],
108
            'source_name': 'hello',
109
            'source_copyright': [{
110
                'file': 'report.spdx',
111
                'sha256': '!!!!value to fill during test!!!!'
112
            }, {
113
                'file': 'LICENSES/CC0-1.0.txt',
114
                'sha256': self.sha256_hashes['LICENSES/CC0-1.0.txt']
115
            }],
116
            'type': 'mapping',
117
	    'identifier': 'helloapple',
118
	    'long_name': 'Hello Apple',
119
	    'uuid': '54d23bba-472e-42f5-9194-eaa24c0e3ee7',
120
	    'version': [2021, 11, 10],
121
	    'description': 'causes apple to get greeted on Hydrillabugs issue tracker',
122
	    'payloads': {
123
	        'https://hydrillabugs.koszko.org/***': {
124
		    'identifier': 'helloapple'
125
	        },
126
	        'https://hachettebugs.koszko.org/***': {
127
		    'identifier': 'helloapple'
128
                }
129
            }
130
        }
131
        self.expected_source_description = {
132
            'api_schema_version': [1, 0, 1],
133
            'source_name': 'hello',
134
            'source_copyright': [{
135
                'file': 'report.spdx',
136
                'sha256': '!!!!value to fill during test!!!!'
137
            }, {
138
                'file': 'LICENSES/CC0-1.0.txt',
139
                'sha256': self.sha256_hashes['LICENSES/CC0-1.0.txt']
140
            }],
141
            'source_archives': {
142
                'zip': {
143
                    'sha256': '!!!!value to fill during test!!!!',
144
                }
145
            },
146
            'upstream_url': 'https://git.koszko.org/hydrilla-source-package-example',
147
            'definitions': [{
148
                'type': 'resource',
149
                'identifier': 'helloapple',
150
                'version': [2021, 11, 10],
151
            }, {
152
                'type':       'resource',
153
                'identifier': 'hello-message',
154
                'version':     [2021, 11, 10],
155
            }, {
156
                'type': 'mapping',
157
                'identifier': 'helloapple',
158
                'version': [2021, 11, 10],
159
            }]
160
        }
161

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

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

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

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

    
187
    settings.srcdir = tmpdir / 'srcdir_copy'
188

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

    
192
    contents = modify_cb(settings, obj)
193

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

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

    
200
    contents = contents.encode()
201

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

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

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

    
210
    return settings
211

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

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

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

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

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

    
238
    settings.report_spdx_included = False
239

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

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

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

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

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

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

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

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

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

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

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

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

    
287
    settings = prepare_source_example(tmpdir)
288

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

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

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

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

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

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

    
309
    spdx_report_sha256 = None
310

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

    
316
        assert spdx_report_sha256 is None and settings.report_spdx_included
317

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    
435
    modify_cb, error_type = break_index_json
436

    
437
    settings = prepare_modified(tmpdir, modify_cb)
438

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