add more validation rules

This commit is contained in:
Evgenii Alekseev 2023-02-23 14:48:22 +02:00
parent 4fb9335df9
commit 13faf66bdb
12 changed files with 165 additions and 54 deletions

View File

@ -1 +1 @@
skips: ['B101', 'B105', 'B106', 'B404'] skips: ['B101', 'B104', 'B105', 'B106', 'B404']

View File

@ -69,7 +69,7 @@ Build related configuration. Group name can refer to architecture, e.g. ``build:
* ``makepkg_flags`` - additional flags passed to ``makepkg`` command, space separated list of strings, optional. * ``makepkg_flags`` - additional flags passed to ``makepkg`` command, space separated list of strings, optional.
* ``makechrootpkg_flags`` - additional flags passed to ``makechrootpkg`` command, space separated list of strings, optional. * ``makechrootpkg_flags`` - additional flags passed to ``makechrootpkg`` command, space separated list of strings, optional.
* ``triggers`` - list of ``ahriman.core.triggers.Trigger`` class implementation (e.g. ``ahriman.core.report.ReportTrigger ahriman.core.upload.UploadTrigger``) which will be loaded and run at the end of processing, space separated list of strings, optional. You can also specify triggers by their paths, e.g. ``/usr/lib/python3.10/site-packages/ahriman/core/report/report.py.ReportTrigger``. Triggers are run in the order of mention. * ``triggers`` - list of ``ahriman.core.triggers.Trigger`` class implementation (e.g. ``ahriman.core.report.ReportTrigger ahriman.core.upload.UploadTrigger``) which will be loaded and run at the end of processing, space separated list of strings, optional. You can also specify triggers by their paths, e.g. ``/usr/lib/python3.10/site-packages/ahriman/core/report/report.py.ReportTrigger``. Triggers are run in the order of mention.
* ``vcs_allowed_age`` - maximal age in seconds of the VCS packages before their version will be updated with its remote source, int, optional, default ``0``. * ``vcs_allowed_age`` - maximal age in seconds of the VCS packages before their version will be updated with its remote source, int, optional, default ``604800``.
``repository`` group ``repository`` group
-------------------- --------------------

View File

@ -52,7 +52,7 @@ class Validate(Handler):
unsafe(bool): if set no user check will be performed before path creation unsafe(bool): if set no user check will be performed before path creation
""" """
schema = Validate.schema(architecture, configuration) schema = Validate.schema(architecture, configuration)
validator = Validator(instance=configuration, schema=schema) validator = Validator(configuration=configuration, schema=schema)
if validator.validate(configuration.dump()): if validator.validate(configuration.dump()):
return # no errors found return # no errors found

View File

@ -64,6 +64,7 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = {
"mirror": { "mirror": {
"type": "string", "type": "string",
"required": True, "required": True,
"is_url": [],
}, },
"repositories": { "repositories": {
"type": "list", "type": "list",
@ -111,10 +112,13 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = {
}, },
"cookie_secret_key": { "cookie_secret_key": {
"type": "string", "type": "string",
"minlength": 32,
"maxlength": 64, # we cannot verify maxlength, because base64 representation might be longer than bytes
}, },
"max_age": { "max_age": {
"type": "integer", "type": "integer",
"coerce": "integer", "coerce": "integer",
"min": 0,
}, },
"oauth_provider": { "oauth_provider": {
"type": "string", "type": "string",
@ -162,6 +166,7 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = {
"vcs_allowed_age": { "vcs_allowed_age": {
"type": "integer", "type": "integer",
"coerce": "integer", "coerce": "integer",
"min": 0,
}, },
}, },
}, },
@ -204,6 +209,7 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = {
"schema": { "schema": {
"address": { "address": {
"type": "string", "type": "string",
"is_url": ["http", "https"],
}, },
"debug": { "debug": {
"type": "boolean", "type": "boolean",
@ -220,9 +226,11 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = {
}, },
"host": { "host": {
"type": "string", "type": "string",
"is_ip_address": ["localhost"],
}, },
"index_url": { "index_url": {
"type": "string", "type": "string",
"is_url": ["http", "https"],
}, },
"password": { "password": {
"type": "string", "type": "string",
@ -258,44 +266,4 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = {
}, },
}, },
}, },
"remote-pull": {
"type": "dict",
"schema": {
"target": {
"type": "list",
"coerce": "list",
"schema": {"type": "string"},
},
},
},
"remote-push": {
"type": "dict",
"schema": {
"target": {
"type": "list",
"coerce": "list",
"schema": {"type": "string"},
},
},
},
"report": {
"type": "dict",
"schema": {
"target": {
"type": "list",
"coerce": "list",
"schema": {"type": "string"},
},
},
},
"upload": {
"type": "dict",
"schema": {
"target": {
"type": "list",
"coerce": "list",
"schema": {"type": "string"},
},
},
},
} }

View File

@ -17,9 +17,12 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
import ipaddress
from cerberus import TypeDefinition, Validator as RootValidator # type: ignore from cerberus import TypeDefinition, Validator as RootValidator # type: ignore
from pathlib import Path from pathlib import Path
from typing import Any, List from typing import Any, List
from urllib.parse import urlparse
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
@ -29,7 +32,7 @@ class Validator(RootValidator): # type: ignore
class which defines custom validation methods for the service configuration class which defines custom validation methods for the service configuration
Attributes: Attributes:
instance(Configuration): configuration instance configuration(Configuration): configuration instance
""" """
types_mapping = RootValidator.types_mapping.copy() types_mapping = RootValidator.types_mapping.copy()
@ -40,12 +43,12 @@ class Validator(RootValidator): # type: ignore
default constructor default constructor
Args: Args:
instance(Configuration): configuration instance used for extraction configuration(Configuration): configuration instance used for extraction
*args(Any): positional arguments to be passed to base validator *args(Any): positional arguments to be passed to base validator
**kwargs(): keyword arguments to be passed to base validator **kwargs(): keyword arguments to be passed to base validator
""" """
RootValidator.__init__(self, *args, **kwargs) RootValidator.__init__(self, *args, **kwargs)
self.instance: Configuration = kwargs["instance"] self.configuration: Configuration = kwargs["configuration"]
def _normalize_coerce_absolute_path(self, value: str) -> Path: def _normalize_coerce_absolute_path(self, value: str) -> Path:
""" """
@ -57,7 +60,7 @@ class Validator(RootValidator): # type: ignore
Returns: Returns:
Path: value converted to path instance according to configuration rules Path: value converted to path instance according to configuration rules
""" """
converted: Path = self.instance.converters["path"](value) converted: Path = self.configuration.converters["path"](value)
return converted return converted
def _normalize_coerce_boolean(self, value: str) -> bool: def _normalize_coerce_boolean(self, value: str) -> bool:
@ -71,7 +74,7 @@ class Validator(RootValidator): # type: ignore
bool: value converted to boolean according to configuration rules bool: value converted to boolean according to configuration rules
""" """
# pylint: disable=protected-access # pylint: disable=protected-access
converted: bool = self.instance._convert_to_boolean(value) # type: ignore converted: bool = self.configuration._convert_to_boolean(value) # type: ignore
return converted return converted
def _normalize_coerce_integer(self, value: str) -> int: def _normalize_coerce_integer(self, value: str) -> int:
@ -97,9 +100,50 @@ class Validator(RootValidator): # type: ignore
Returns: Returns:
List[str]: value converted to string list instance according to configuration rules List[str]: value converted to string list instance according to configuration rules
""" """
converted: List[str] = self.instance.converters["list"](value) converted: List[str] = self.configuration.converters["list"](value)
return converted return converted
def _validate_is_ip_address(self, constraint: List[str], field: str, value: str) -> None:
"""
check if the specified value is valid ip address
Args:
constraint(List[str]): optional list of allowed special words (e.g. ``localhost``)
field(str): field name to be checked
value(Path): value to be checked
Examples:
The rule's arguments are validated against this schema:
{"type": "list", "schema": {"type": "string"}}
"""
if value in constraint:
return
try:
ipaddress.ip_address(value)
except ValueError:
self._error(field, f"Value {value} must be valid IP address")
def _validate_is_url(self, constraint: List[str], field: str, value: str) -> None:
"""
check if the specified value is a valid url
Args:
constraint(List[str]): optional list of supported schemas. If empty, no schema validation will be performed
field(str): field name to be checked
value(str): value to be checked
Examples:
The rule's arguments are validated against this schema:
{"type": "list", "schema": {"type": "string"}}
"""
url = urlparse(value) # it probably will never rise exceptions on parse
if not url.scheme:
self._error(field, f"Url scheme is not set for {value}")
if not url.netloc and url.scheme not in ("file",):
self._error(field, f"Location must be set for url {value} of scheme {url.scheme}")
if constraint and url.scheme not in constraint:
self._error(field, f"Url {value} scheme must be one of {constraint}")
def _validate_path_exists(self, constraint: bool, field: str, value: Path) -> None: def _validate_path_exists(self, constraint: bool, field: str, value: Path) -> None:
""" """
check if paths exists check if paths exists

View File

@ -33,6 +33,16 @@ class RemotePullTrigger(Trigger):
""" """
CONFIGURATION_SCHEMA = { CONFIGURATION_SCHEMA = {
"remote-pull": {
"type": "dict",
"schema": {
"target": {
"type": "list",
"coerce": "list",
"schema": {"type": "string"},
},
},
},
"gitremote": { "gitremote": {
"type": "dict", "type": "dict",
"schema": { "schema": {

View File

@ -38,6 +38,16 @@ class RemotePushTrigger(Trigger):
""" """
CONFIGURATION_SCHEMA = { CONFIGURATION_SCHEMA = {
"remote-push": {
"type": "dict",
"schema": {
"target": {
"type": "list",
"coerce": "list",
"schema": {"type": "string"},
},
},
},
"gitremote": { "gitremote": {
"type": "dict", "type": "dict",
"schema": { "schema": {

View File

@ -35,6 +35,16 @@ class ReportTrigger(Trigger):
""" """
CONFIGURATION_SCHEMA = { CONFIGURATION_SCHEMA = {
"report": {
"type": "dict",
"schema": {
"target": {
"type": "list",
"coerce": "list",
"schema": {"type": "string"},
},
},
},
"console": { "console": {
"type": "dict", "type": "dict",
"schema": { "schema": {
@ -62,6 +72,7 @@ class ReportTrigger(Trigger):
}, },
"homepage": { "homepage": {
"type": "string", "type": "string",
"is_url": ["http", "https"],
}, },
"host": { "host": {
"type": "string", "type": "string",
@ -70,6 +81,7 @@ class ReportTrigger(Trigger):
"link_path": { "link_path": {
"type": "string", "type": "string",
"required": True, "required": True,
"is_url": [],
}, },
"no_empty_report": { "no_empty_report": {
"type": "boolean", "type": "boolean",
@ -82,6 +94,8 @@ class ReportTrigger(Trigger):
"type": "integer", "type": "integer",
"coerce": "integer", "coerce": "integer",
"required": True, "required": True,
"min": 0,
"max": 65535,
}, },
"receivers": { "receivers": {
"type": "list", "type": "list",
@ -118,10 +132,12 @@ class ReportTrigger(Trigger):
}, },
"homepage": { "homepage": {
"type": "string", "type": "string",
"is_url": ["http", "https"],
}, },
"link_path": { "link_path": {
"type": "string", "type": "string",
"required": True, "required": True,
"is_url": [],
}, },
"path": { "path": {
"type": "path", "type": "path",
@ -153,10 +169,12 @@ class ReportTrigger(Trigger):
}, },
"homepage": { "homepage": {
"type": "string", "type": "string",
"is_url": ["http", "https"],
}, },
"link_path": { "link_path": {
"type": "string", "type": "string",
"required": True, "required": True,
"is_url": [],
}, },
"template_path": { "template_path": {
"type": "path", "type": "path",
@ -171,6 +189,7 @@ class ReportTrigger(Trigger):
"timeout": { "timeout": {
"type": "integer", "type": "integer",
"coerce": "integer", "coerce": "integer",
"min": 0,
}, },
}, },
}, },

View File

@ -35,6 +35,16 @@ class UploadTrigger(Trigger):
""" """
CONFIGURATION_SCHEMA = { CONFIGURATION_SCHEMA = {
"upload": {
"type": "dict",
"schema": {
"target": {
"type": "list",
"coerce": "list",
"schema": {"type": "string"},
},
},
},
"github": { "github": {
"type": "dict", "type": "dict",
"schema": { "schema": {
@ -57,6 +67,7 @@ class UploadTrigger(Trigger):
"timeout": { "timeout": {
"type": "integer", "type": "integer",
"coerce": "integer", "coerce": "integer",
"min": 0,
}, },
"username": { "username": {
"type": "string", "type": "string",
@ -101,6 +112,7 @@ class UploadTrigger(Trigger):
"chunk_size": { "chunk_size": {
"type": "integer", "type": "integer",
"coerce": "integer", "coerce": "integer",
"min": 0,
}, },
"region": { "region": {
"type": "string", "type": "string",

View File

@ -62,9 +62,11 @@ def test_schema(configuration: Configuration) -> None:
assert schema.pop("email") assert schema.pop("email")
assert schema.pop("github") assert schema.pop("github")
assert schema.pop("html") assert schema.pop("html")
assert schema.pop("report")
assert schema.pop("rsync") assert schema.pop("rsync")
assert schema.pop("s3") assert schema.pop("s3")
assert schema.pop("telegram") assert schema.pop("telegram")
assert schema.pop("upload")
assert schema == CONFIGURATION_SCHEMA assert schema == CONFIGURATION_SCHEMA

View File

@ -16,4 +16,4 @@ def validator(configuration: Configuration) -> Validator:
Returns: Returns:
Validator: validator test instance Validator: validator test instance
""" """
return Validator(instance=configuration, schema=CONFIGURATION_SCHEMA) return Validator(configuration=configuration, schema=CONFIGURATION_SCHEMA)

View File

@ -1,6 +1,6 @@
from pathlib import Path from pathlib import Path
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from unittest.mock import MagicMock from unittest.mock import MagicMock, call as MockCall
from ahriman.core.configuration.validator import Validator from ahriman.core.configuration.validator import Validator
@ -18,7 +18,7 @@ def test_normalize_coerce_absolute_path(validator: Validator) -> None:
must convert string value to path by using configuration converters must convert string value to path by using configuration converters
""" """
convert_mock = MagicMock() convert_mock = MagicMock()
validator.instance.converters["path"] = convert_mock validator.configuration.converters["path"] = convert_mock
validator._normalize_coerce_absolute_path("value") validator._normalize_coerce_absolute_path("value")
convert_mock.assert_called_once_with("value") convert_mock.assert_called_once_with("value")
@ -46,12 +46,56 @@ def test_normalize_coerce_list(validator: Validator) -> None:
must convert string value to list by using configuration converters must convert string value to list by using configuration converters
""" """
convert_mock = MagicMock() convert_mock = MagicMock()
validator.instance.converters["list"] = convert_mock validator.configuration.converters["list"] = convert_mock
validator._normalize_coerce_list("value") validator._normalize_coerce_list("value")
convert_mock.assert_called_once_with("value") convert_mock.assert_called_once_with("value")
def test_validate_is_ip_address(validator: Validator, mocker: MockerFixture) -> None:
"""
must validate addresses correctly
"""
error_mock = mocker.patch("ahriman.core.configuration.validator.Validator._error")
validator._validate_is_ip_address(["localhost"], "field", "localhost")
validator._validate_is_ip_address([], "field", "localhost")
validator._validate_is_ip_address([], "field", "127.0.0.1")
validator._validate_is_ip_address([], "field", "::")
validator._validate_is_ip_address([], "field", "0.0.0.0")
validator._validate_is_ip_address([], "field", "random string")
error_mock.assert_has_calls([
MockCall("field", "Value localhost must be valid IP address"),
MockCall("field", "Value random string must be valid IP address"),
])
def test_validate_is_url(validator: Validator, mocker: MockerFixture) -> None:
"""
must validate url correctly
"""
error_mock = mocker.patch("ahriman.core.configuration.validator.Validator._error")
validator._validate_is_url([], "field", "http://example.com")
validator._validate_is_url([], "field", "https://example.com")
validator._validate_is_url([], "field", "file:///tmp")
validator._validate_is_url(["http", "https"], "field", "file:///tmp")
validator._validate_is_url([], "field", "http:///path")
validator._validate_is_url([], "field", "random string")
error_mock.assert_has_calls([
MockCall("field", "Url file:///tmp scheme must be one of ['http', 'https']"),
MockCall("field", "Location must be set for url http:///path of scheme http"),
MockCall("field", "Url scheme is not set for random string"),
])
def test_validate_path_exists(validator: Validator, mocker: MockerFixture) -> None: def test_validate_path_exists(validator: Validator, mocker: MockerFixture) -> None:
""" """
must validate that paths exists must validate that paths exists
@ -67,4 +111,6 @@ def test_validate_path_exists(validator: Validator, mocker: MockerFixture) -> No
mocker.patch("pathlib.Path.exists", return_value=True) mocker.patch("pathlib.Path.exists", return_value=True)
validator._validate_path_exists(True, "field", Path("3")) validator._validate_path_exists(True, "field", Path("3"))
error_mock.assert_called_once_with("field", "Path 2 must exist") error_mock.assert_has_calls([
MockCall("field", "Path 2 must exist"),
])