From 4d8810538798aa815a459f298349a03fc6b3ea3f Mon Sep 17 00:00:00 2001 From: Wojtek Kosior Date: Fri, 22 Apr 2022 16:43:11 +0200 Subject: use pyproject.toml for pytest configuration and move all tests to tests/ directory this commit also fixes inclusion of sample source package's REUSE data in source dist (needed for automated tests) --- .gitmodules | 2 +- MANIFEST.in | 4 +- pyproject.toml | 7 ++ src/conftest.py | 0 src/test/__init__.py | 5 - src/test/config.json | 33 ----- src/test/source-package-example | 1 - src/test/test_server.py | 273 ---------------------------------------- tests/__init__.py | 5 + tests/config.json | 33 +++++ tests/source-package-example | 1 + tests/test_server.py | 273 ++++++++++++++++++++++++++++++++++++++++ 12 files changed, 323 insertions(+), 314 deletions(-) delete mode 100644 src/conftest.py delete mode 100644 src/test/__init__.py delete mode 100644 src/test/config.json delete mode 160000 src/test/source-package-example delete mode 100644 src/test/test_server.py create mode 100644 tests/__init__.py create mode 100644 tests/config.json create mode 160000 tests/source-package-example create mode 100644 tests/test_server.py diff --git a/.gitmodules b/.gitmodules index 6528d07..271d652 100644 --- a/.gitmodules +++ b/.gitmodules @@ -5,5 +5,5 @@ # Available under the terms of Creative Commons Zero v1.0 Universal. [submodule "src/test/source-package-example"] - path = src/test/source-package-example + path = tests/source-package-example url = ../hydrilla-source-package-example/ diff --git a/MANIFEST.in b/MANIFEST.in index 900e535..558e461 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -5,5 +5,7 @@ # Available under the terms of Creative Commons Zero v1.0 Universal. include src/hydrilla/server/locales/*/LC_MESSAGES/hydrilla-messages.po -include src/test/source-package-example/* +include tests/source-package-example/* +include tests/source-package-example/LICENSES/* +include tests/source-package-example/.reuse/* global-exclude .git .gitignore .gitmodules diff --git a/pyproject.toml b/pyproject.toml index 39e2823..b6f87c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,3 +10,10 @@ requires = ["setuptools>=45", "wheel", "setuptools_scm>=5.0", "babel"] [tool.setuptools_scm] write_to = "src/hydrilla/server/_version.py" + +[tool.pytest.ini_options] +minversion = "6.0" +addopts = "-ra -q" +testpaths = [ + "tests" +] diff --git a/src/conftest.py b/src/conftest.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/test/__init__.py b/src/test/__init__.py deleted file mode 100644 index d382ead..0000000 --- a/src/test/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -# SPDX-License-Identifier: CC0-1.0 - -# Copyright (C) 2022 Wojtek Kosior -# -# Available under the terms of Creative Commons Zero v1.0 Universal. diff --git a/src/test/config.json b/src/test/config.json deleted file mode 100644 index a75e2b1..0000000 --- a/src/test/config.json +++ /dev/null @@ -1,33 +0,0 @@ -// SPDX-License-Identifier: CC0-1.0 - -// Hydrilla development config file. -// -// Copyright (C) 2021, 2022 Wojtek Kosior -// -// Available under the terms of Creative Commons Zero v1.0 Universal. - -// this config is meant to be used in development environment; unlike -// src/hydrilla/server/config.json, it shall not be included in distribution -{ - // Relative paths now get resolved from config's containing direcotry. - "malcontent_dir": "./sample_malcontent", - - // Hydrilla will display this link to users as a place where they can - // obtain sources for its software. This config option is meant to ease - // compliance with the AGPL. - "hydrilla_project_url": "https://hydrillabugs.koszko.org/projects/hydrilla/wiki", - - // Port to listen on (not relevant when Flask.test_client() is used). - "port": 10112, - - // Use english for HTML files and generated messages. - "language": "en_US", - - // Make Hydrilla error out on any warning - "werror": true - - // With the below we can make hydrilla look for missing content items in - // another instance instead of just erroring/warning. - // TODO: feature not implemented - // ,"hydrilla_parent": "https://api.hydrilla.koszko.org/1.0/" -} diff --git a/src/test/source-package-example b/src/test/source-package-example deleted file mode 160000 index 92a4d31..0000000 --- a/src/test/source-package-example +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 92a4d31c659b2336e5e188877d1ce6bfad2fa310 diff --git a/src/test/test_server.py b/src/test/test_server.py deleted file mode 100644 index 1da5663..0000000 --- a/src/test/test_server.py +++ /dev/null @@ -1,273 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later - -# Repository tests -# -# This file is part of Hydrilla -# -# Copyright (C) 2021, 2022 Wojtek Kosior -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -# -# -# I, Wojtek Kosior, thereby promise not to sue for violation of this -# file's license. Although I request that you do not make use this code -# in a proprietary program, I am not going to enforce this in court. - -# Enable using with Python 3.7. -from __future__ import annotations - -import pytest -import sys -import shutil -import json - -from pathlib import Path -from hashlib import sha256 -from tempfile import TemporaryDirectory -from typing import Callable, Optional - -from flask.testing import FlaskClient -from markupsafe import escape -from werkzeug import Response - -from hydrilla import util as hydrilla_util -from hydrilla.builder import Build -from hydrilla.server import HydrillaApp, config, _version - -here = Path(__file__).resolve().parent -config_path = here / 'config.json' -source_path = here / 'source-package-example' - -expected_generated_by = { - 'name': 'hydrilla.server', - 'version': _version.version -} - -SetupMod = Optional[Callable['Setup', None]] - -source_files = ( - 'index.json', 'hello.js', 'bye.js', 'message.js', 'README.txt', - 'README.txt.license', '.reuse/dep5', 'LICENSES/CC0-1.0.txt' -) - -class Setup: - """ - Facilitate preparing test malcontent directory, Hydrilla config file and the - actual Flask client. In a customizable way. - """ - def __init__(self, modify_before_build: SetupMod=None, - modify_after_build: SetupMod=None) -> None: - """Initialize Setup.""" - self._modify_before_build = modify_before_build - self._modify_after_build = modify_after_build - self._config = None - self._client = None - - def _prepare(self) -> None: - """Perform the build and call the callbacks as appropriate.""" - self.tmpdir = TemporaryDirectory() - - self.containing_dir = Path(self.tmpdir.name) - self.malcontent_dir = self.containing_dir / 'sample_malcontent' - self.index_json = Path('index.json') - - self.source_dir = self.containing_dir / 'sample_source_package' - for source_file in source_files: - dst_path = self.source_dir / source_file - dst_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(source_path / source_file, dst_path) - - self.config_path = self.containing_dir / 'config.json' - shutil.copyfile(config_path, self.config_path) - - if self._modify_before_build: - self._modify_before_build(self) - - build = Build(self.source_dir, self.index_json) - build.write_package_files(self.malcontent_dir) - - if self._modify_after_build: - self._modify_after_build(self) - - def config(self) -> dict: - """Provide the contents of JSON config file used.""" - if self._config is None: - self._prepare() - self._config = config.load([self.config_path]) - - return self._config - - def client(self) -> FlaskClient: - """ - Provide app client that serves the objects from built sample package. - """ - if self._client is None: - app = HydrillaApp(self.config(), flask_config={'TESTING': True}) - self._client = app.test_client() - - return self._client - -def remove_all_uuids(setup: Setup) -> None: - """Modify sample packages before build to contain no (optional) UUIDs""" - index_json = (setup.source_dir / 'index.json').read_text() - index_json = json.loads(hydrilla_util.strip_json_comments(index_json)) - - for definition in index_json['definitions']: - del definition['uuid'] - - index_json = ("// SPDX-License-Identifier: CC0-1.0\n" + - "// Copyright (C) 2021, 2022 Wojtek Kosior\n" + - json.dumps(index_json)) - - (setup.source_dir / 'index.json').write_text(index_json) - -default_setup = Setup() -uuidless_setup = Setup(modify_before_build=remove_all_uuids) - -def def_get(url: str) -> Response: - """Convenience wrapper for def_get()""" - return default_setup.client().get(url) - -def test_project_url() -> None: - """Fetch index.html and verify project URL from config is present there.""" - response = def_get('/') - assert b'html' in response.data - project_url = default_setup.config()['hydrilla_project_url'] - assert escape(project_url).encode() in response.data - -@pytest.mark.parametrize('setup', [default_setup, uuidless_setup]) -@pytest.mark.parametrize('item_type', ['resource', 'mapping']) -def test_get_newest(setup: Setup, item_type: str) -> None: - """ - Verify that - GET '/{item_type}/{item_identifier}.json' - returns proper definition that is also served at: - GET '/{item_type}/{item_identifier}/{item_version}' - """ - response = setup.client().get(f'/{item_type}/helloapple.json') - assert response.status_code == 200 - definition = json.loads(response.data.decode()) - assert definition['type'] == item_type - assert definition['identifier'] == 'helloapple' - - response = setup.client().get(f'/{item_type}/helloapple/2021.11.10') - assert response.status_code == 200 - assert definition == json.loads(response.data.decode()) - - assert ('uuid' in definition) == (setup is not uuidless_setup) - - hydrilla_util.validator_for(f'api_{item_type}_description-1.0.1.schema.json')\ - .validate(definition) - -@pytest.mark.parametrize('item_type', ['resource', 'mapping']) -def test_get_nonexistent(item_type: str) -> None: - """ - Verify that attempts to GET a JSON definition of a nonexistent item or item - version result in 404. - """ - response = def_get(f'/{item_type}/nonexistentapple.json') - assert response.status_code == 404 - response = def_get(f'/{item_type}/helloapple/1.2.3.999') - assert response.status_code == 404 - -@pytest.mark.parametrize('item_type', ['resource', 'mapping']) -def test_file_refs(item_type: str) -> None: - """ - Verify that files referenced by definitions are accessible under their - proper URLs and that their hashes match. - """ - response = def_get(f'/{item_type}/helloapple/2021.11.10') - assert response.status_code == 200 - definition = json.loads(response.data.decode()) - - for file_ref in [*definition.get('scripts', []), - *definition['source_copyright']]: - hash_sum = file_ref["sha256"] - response = def_get(f'/file/sha256/{hash_sum}') - - assert response.status_code == 200 - assert sha256(response.data).digest().hex() == hash_sum - -def test_empty_query() -> None: - """ - Verify that querying mappings for URL gives an empty list when there're no - mathes. - """ - response = def_get(f'/query?url=https://nonexiste.nt/example') - assert response.status_code == 200 - - response_object = json.loads(response.data.decode()) - - assert response_object == { - '$schema': 'https://hydrilla.koszko.org/schemas/api_query_result-1.schema.json', - 'mappings': [], - 'generated_by': expected_generated_by - } - - hydrilla_util.validator_for('api_query_result-1.0.1.schema.json')\ - .validate(response_object) - -def test_query() -> None: - """ - Verify that querying mappings for URL gives a list with reference(s) the the - matching mapping(s). - """ - response = def_get(f'/query?url=https://hydrillabugs.koszko.org/') - assert response.status_code == 200 - - response_object = json.loads(response.data.decode()) - - assert response_object == { - '$schema': 'https://hydrilla.koszko.org/schemas/api_query_result-1.schema.json', - 'mappings': [{ - 'identifier': 'helloapple', - 'long_name': 'Hello Apple', - 'version': [2021, 11, 10] - }], - 'generated_by': expected_generated_by - } - - hydrilla_util.validator_for('api_query_result-1.0.1.schema.json')\ - .validate(response_object) - -def test_source() -> None: - """Verify source descriptions are properly served.""" - response = def_get(f'/source/hello.json') - assert response.status_code == 200 - - description = json.loads(response.data.decode()) - assert description['source_name'] == 'hello' - - assert sorted([d['identifier'] for d in description['definitions']]) == \ - ['hello-message', 'helloapple', 'helloapple'] - - zipfile_hash = description['source_archives']['zip']['sha256'] - response = def_get(f'/source/hello.zip') - assert sha256(response.data).digest().hex() == zipfile_hash - - hydrilla_util.validator_for('api_source_description-1.0.1.schema.json')\ - .validate(description) - -def test_missing_source() -> None: - """Verify requests for nonexistent sources result in 404.""" - response = def_get(f'/source/nonexistent.json') - assert response.status_code == 404 - - response = def_get(f'/source/nonexistent.zip') - assert response.status_code == 404 - -def test_normalize_version(): - assert hydrilla_util.normalize_version([4, 5, 3, 0, 0]) == [4, 5, 3] - assert hydrilla_util.normalize_version([1, 0, 5, 0]) == [1, 0, 5] - assert hydrilla_util.normalize_version([3, 3]) == [3, 3] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..d382ead --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,5 @@ +# SPDX-License-Identifier: CC0-1.0 + +# Copyright (C) 2022 Wojtek Kosior +# +# Available under the terms of Creative Commons Zero v1.0 Universal. diff --git a/tests/config.json b/tests/config.json new file mode 100644 index 0000000..a75e2b1 --- /dev/null +++ b/tests/config.json @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: CC0-1.0 + +// Hydrilla development config file. +// +// Copyright (C) 2021, 2022 Wojtek Kosior +// +// Available under the terms of Creative Commons Zero v1.0 Universal. + +// this config is meant to be used in development environment; unlike +// src/hydrilla/server/config.json, it shall not be included in distribution +{ + // Relative paths now get resolved from config's containing direcotry. + "malcontent_dir": "./sample_malcontent", + + // Hydrilla will display this link to users as a place where they can + // obtain sources for its software. This config option is meant to ease + // compliance with the AGPL. + "hydrilla_project_url": "https://hydrillabugs.koszko.org/projects/hydrilla/wiki", + + // Port to listen on (not relevant when Flask.test_client() is used). + "port": 10112, + + // Use english for HTML files and generated messages. + "language": "en_US", + + // Make Hydrilla error out on any warning + "werror": true + + // With the below we can make hydrilla look for missing content items in + // another instance instead of just erroring/warning. + // TODO: feature not implemented + // ,"hydrilla_parent": "https://api.hydrilla.koszko.org/1.0/" +} diff --git a/tests/source-package-example b/tests/source-package-example new file mode 160000 index 0000000..92a4d31 --- /dev/null +++ b/tests/source-package-example @@ -0,0 +1 @@ +Subproject commit 92a4d31c659b2336e5e188877d1ce6bfad2fa310 diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..1da5663 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,273 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later + +# Repository tests +# +# This file is part of Hydrilla +# +# Copyright (C) 2021, 2022 Wojtek Kosior +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# +# I, Wojtek Kosior, thereby promise not to sue for violation of this +# file's license. Although I request that you do not make use this code +# in a proprietary program, I am not going to enforce this in court. + +# Enable using with Python 3.7. +from __future__ import annotations + +import pytest +import sys +import shutil +import json + +from pathlib import Path +from hashlib import sha256 +from tempfile import TemporaryDirectory +from typing import Callable, Optional + +from flask.testing import FlaskClient +from markupsafe import escape +from werkzeug import Response + +from hydrilla import util as hydrilla_util +from hydrilla.builder import Build +from hydrilla.server import HydrillaApp, config, _version + +here = Path(__file__).resolve().parent +config_path = here / 'config.json' +source_path = here / 'source-package-example' + +expected_generated_by = { + 'name': 'hydrilla.server', + 'version': _version.version +} + +SetupMod = Optional[Callable['Setup', None]] + +source_files = ( + 'index.json', 'hello.js', 'bye.js', 'message.js', 'README.txt', + 'README.txt.license', '.reuse/dep5', 'LICENSES/CC0-1.0.txt' +) + +class Setup: + """ + Facilitate preparing test malcontent directory, Hydrilla config file and the + actual Flask client. In a customizable way. + """ + def __init__(self, modify_before_build: SetupMod=None, + modify_after_build: SetupMod=None) -> None: + """Initialize Setup.""" + self._modify_before_build = modify_before_build + self._modify_after_build = modify_after_build + self._config = None + self._client = None + + def _prepare(self) -> None: + """Perform the build and call the callbacks as appropriate.""" + self.tmpdir = TemporaryDirectory() + + self.containing_dir = Path(self.tmpdir.name) + self.malcontent_dir = self.containing_dir / 'sample_malcontent' + self.index_json = Path('index.json') + + self.source_dir = self.containing_dir / 'sample_source_package' + for source_file in source_files: + dst_path = self.source_dir / source_file + dst_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source_path / source_file, dst_path) + + self.config_path = self.containing_dir / 'config.json' + shutil.copyfile(config_path, self.config_path) + + if self._modify_before_build: + self._modify_before_build(self) + + build = Build(self.source_dir, self.index_json) + build.write_package_files(self.malcontent_dir) + + if self._modify_after_build: + self._modify_after_build(self) + + def config(self) -> dict: + """Provide the contents of JSON config file used.""" + if self._config is None: + self._prepare() + self._config = config.load([self.config_path]) + + return self._config + + def client(self) -> FlaskClient: + """ + Provide app client that serves the objects from built sample package. + """ + if self._client is None: + app = HydrillaApp(self.config(), flask_config={'TESTING': True}) + self._client = app.test_client() + + return self._client + +def remove_all_uuids(setup: Setup) -> None: + """Modify sample packages before build to contain no (optional) UUIDs""" + index_json = (setup.source_dir / 'index.json').read_text() + index_json = json.loads(hydrilla_util.strip_json_comments(index_json)) + + for definition in index_json['definitions']: + del definition['uuid'] + + index_json = ("// SPDX-License-Identifier: CC0-1.0\n" + + "// Copyright (C) 2021, 2022 Wojtek Kosior\n" + + json.dumps(index_json)) + + (setup.source_dir / 'index.json').write_text(index_json) + +default_setup = Setup() +uuidless_setup = Setup(modify_before_build=remove_all_uuids) + +def def_get(url: str) -> Response: + """Convenience wrapper for def_get()""" + return default_setup.client().get(url) + +def test_project_url() -> None: + """Fetch index.html and verify project URL from config is present there.""" + response = def_get('/') + assert b'html' in response.data + project_url = default_setup.config()['hydrilla_project_url'] + assert escape(project_url).encode() in response.data + +@pytest.mark.parametrize('setup', [default_setup, uuidless_setup]) +@pytest.mark.parametrize('item_type', ['resource', 'mapping']) +def test_get_newest(setup: Setup, item_type: str) -> None: + """ + Verify that + GET '/{item_type}/{item_identifier}.json' + returns proper definition that is also served at: + GET '/{item_type}/{item_identifier}/{item_version}' + """ + response = setup.client().get(f'/{item_type}/helloapple.json') + assert response.status_code == 200 + definition = json.loads(response.data.decode()) + assert definition['type'] == item_type + assert definition['identifier'] == 'helloapple' + + response = setup.client().get(f'/{item_type}/helloapple/2021.11.10') + assert response.status_code == 200 + assert definition == json.loads(response.data.decode()) + + assert ('uuid' in definition) == (setup is not uuidless_setup) + + hydrilla_util.validator_for(f'api_{item_type}_description-1.0.1.schema.json')\ + .validate(definition) + +@pytest.mark.parametrize('item_type', ['resource', 'mapping']) +def test_get_nonexistent(item_type: str) -> None: + """ + Verify that attempts to GET a JSON definition of a nonexistent item or item + version result in 404. + """ + response = def_get(f'/{item_type}/nonexistentapple.json') + assert response.status_code == 404 + response = def_get(f'/{item_type}/helloapple/1.2.3.999') + assert response.status_code == 404 + +@pytest.mark.parametrize('item_type', ['resource', 'mapping']) +def test_file_refs(item_type: str) -> None: + """ + Verify that files referenced by definitions are accessible under their + proper URLs and that their hashes match. + """ + response = def_get(f'/{item_type}/helloapple/2021.11.10') + assert response.status_code == 200 + definition = json.loads(response.data.decode()) + + for file_ref in [*definition.get('scripts', []), + *definition['source_copyright']]: + hash_sum = file_ref["sha256"] + response = def_get(f'/file/sha256/{hash_sum}') + + assert response.status_code == 200 + assert sha256(response.data).digest().hex() == hash_sum + +def test_empty_query() -> None: + """ + Verify that querying mappings for URL gives an empty list when there're no + mathes. + """ + response = def_get(f'/query?url=https://nonexiste.nt/example') + assert response.status_code == 200 + + response_object = json.loads(response.data.decode()) + + assert response_object == { + '$schema': 'https://hydrilla.koszko.org/schemas/api_query_result-1.schema.json', + 'mappings': [], + 'generated_by': expected_generated_by + } + + hydrilla_util.validator_for('api_query_result-1.0.1.schema.json')\ + .validate(response_object) + +def test_query() -> None: + """ + Verify that querying mappings for URL gives a list with reference(s) the the + matching mapping(s). + """ + response = def_get(f'/query?url=https://hydrillabugs.koszko.org/') + assert response.status_code == 200 + + response_object = json.loads(response.data.decode()) + + assert response_object == { + '$schema': 'https://hydrilla.koszko.org/schemas/api_query_result-1.schema.json', + 'mappings': [{ + 'identifier': 'helloapple', + 'long_name': 'Hello Apple', + 'version': [2021, 11, 10] + }], + 'generated_by': expected_generated_by + } + + hydrilla_util.validator_for('api_query_result-1.0.1.schema.json')\ + .validate(response_object) + +def test_source() -> None: + """Verify source descriptions are properly served.""" + response = def_get(f'/source/hello.json') + assert response.status_code == 200 + + description = json.loads(response.data.decode()) + assert description['source_name'] == 'hello' + + assert sorted([d['identifier'] for d in description['definitions']]) == \ + ['hello-message', 'helloapple', 'helloapple'] + + zipfile_hash = description['source_archives']['zip']['sha256'] + response = def_get(f'/source/hello.zip') + assert sha256(response.data).digest().hex() == zipfile_hash + + hydrilla_util.validator_for('api_source_description-1.0.1.schema.json')\ + .validate(description) + +def test_missing_source() -> None: + """Verify requests for nonexistent sources result in 404.""" + response = def_get(f'/source/nonexistent.json') + assert response.status_code == 404 + + response = def_get(f'/source/nonexistent.zip') + assert response.status_code == 404 + +def test_normalize_version(): + assert hydrilla_util.normalize_version([4, 5, 3, 0, 0]) == [4, 5, 3] + assert hydrilla_util.normalize_version([1, 0, 5, 0]) == [1, 0, 5] + assert hydrilla_util.normalize_version([3, 3]) == [3, 3] -- cgit v1.2.3