diff options
Diffstat (limited to 'tests/forms')
| -rw-r--r-- | tests/forms/test_forms.py | 115 | ||||
| -rw-r--r-- | tests/forms/test_validators.py | 114 |
2 files changed, 229 insertions, 0 deletions
diff --git a/tests/forms/test_forms.py b/tests/forms/test_forms.py new file mode 100644 index 0000000..6fe1849 --- /dev/null +++ b/tests/forms/test_forms.py @@ -0,0 +1,115 @@ +# SPDX-FileCopyrightText: 2026 Dennis Fink <me+coding@dennisfink.me> +# +# SPDX-License-Identifier: BSD-3-Clause + +from unittest.mock import Mock + +import pytest +from flask import Flask +from werkzeug.datastructures import MultiDict + +from webmentions_ssg.forms import AdminActionForm, EndpointForm, LoginForm + +VALID_SOURCE = "https://source.example/post" +VALID_TARGET = "https://dennisfink.me/blog/example/" + + +@pytest.fixture(autouse=True) +def public_urls(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "webmentions_ssg.forms.validators.is_public_url", Mock(return_value=True) + ) + + +@pytest.mark.parametrize( + ("form_data", "invalid_field", "expected_error"), + [ + pytest.param( + {"target": VALID_TARGET}, + "source", + "This field is required.", + id="source-required", + ), + pytest.param( + {"source": "not a URL", "target": VALID_TARGET}, + "source", + "Invalid URL.", + id="source-url", + ), + pytest.param( + {"source": "ftp://source.example/post", "target": VALID_TARGET}, + "source", + "source must begin with http or https", + id="source-scheme", + ), + pytest.param( + {"source": VALID_SOURCE}, + "target", + "This field is required.", + id="target-required", + ), + pytest.param( + {"source": VALID_SOURCE, "target": "not a URL"}, + "target", + "Invalid URL.", + id="target-url", + ), + pytest.param( + {"source": VALID_SOURCE, "target": "ftp://dennisfink.me/blog/example/"}, + "target", + "target must begin with http or https", + id="target-scheme", + ), + ], +) +def test_endpoint_form_rejects_invalid_field_syntax( + app: Flask, form_data: dict[str, str], invalid_field: str, expected_error: str +) -> None: + with app.test_request_context("/endpoint", method="POST"): + form = EndpointForm(formdata=MultiDict(form_data), meta={"csrf": False}) + + assert not form.validate() + assert expected_error in form.errors[invalid_field] + + +def test_endpoint_form_accepts_valid_data(app: Flask) -> None: + with app.test_request_context("/endpoint", method="POST"): + form = EndpointForm( + formdata=MultiDict({"source": VALID_SOURCE, "target": VALID_TARGET}), + meta={"csrf": False}, + ) + + assert form.validate() + assert form.errors == {} + + +@pytest.mark.parametrize( + ("form_data", "invalid_field"), + [ + pytest.param({"password": "secret"}, "username", id="username-required"), + pytest.param({"username": "admin"}, "password", id="password-required"), + ], +) +def test_login_form_requires_credentials( + app: Flask, form_data: dict[str, str], invalid_field: str +) -> None: + with app.test_request_context("/login", method="POST"): + form = LoginForm(formdata=MultiDict(form_data), meta={"csrf": False}) + + assert not form.validate() + assert "This field is required." in form.errors[invalid_field] + + +def test_login_form_accepts_credentials(app: Flask) -> None: + with app.test_request_context("/login", method="POST"): + form = LoginForm( + formdata=MultiDict({"username": "admin", "password": "secret"}), + meta={"csrf": False}, + ) + assert form.validate() + + +def test_admin_action_form_accepts_submission(app: Flask) -> None: + with app.test_request_context(method="POST"): + form = AdminActionForm(meta={"csrf": False}) + assert form.validate() diff --git a/tests/forms/test_validators.py b/tests/forms/test_validators.py new file mode 100644 index 0000000..c63ee54 --- /dev/null +++ b/tests/forms/test_validators.py @@ -0,0 +1,114 @@ +# SPDX-FileCopyrightText: 2026 Dennis Fink <me+coding@dennisfink.me> +# +# SPDX-License-Identifier: BSD-3-Clause + +from unittest.mock import Mock + +import pytest +from flask import Flask +from werkzeug.datastructures import MultiDict +from wtforms import Form, StringField, ValidationError + +from webmentions_ssg.forms.validators import AllowedHostname, NotEqualTo, PublicURL +from webmentions_ssg.url_security import AddressResolutionError + + +class ComparisonForm(Form): + source = StringField("Source") + target = StringField("Target") + + +class URLForm(Form): + url = StringField("URL") + + +def test_not_equal_to_accepts_different_values() -> None: + form = ComparisonForm(MultiDict({"source": "source", "target": "target"})) + NotEqualTo("target")(form, form.source) + + +def test_not_equal_to_rejects_equal_values() -> None: + form = ComparisonForm(MultiDict({"source": "same", "target": "same"})) + with pytest.raises(ValidationError, match="Field must not be equal to target"): + NotEqualTo("target")(form, form.source) + + +def test_not_equal_to_uses_custom_message() -> None: + form = ComparisonForm(MultiDict({"source": "same", "target": "same"})) + with pytest.raises(ValidationError, match="Must differ from Target"): + NotEqualTo("target", "Must differ from %(other_label)s")(form, form.source) + + +def test_not_equal_to_rejects_unknown_field() -> None: + form = ComparisonForm(MultiDict({"source": "source", "target": "target"})) + with pytest.raises(ValidationError, match="Invalid field name 'missing'"): + NotEqualTo("missing")(form, form.source) + + +def test_allowed_hostname_accepts_configured_hostname(app: Flask) -> None: + app.config["WEBMENTIONS_SSG_ALLOWED_HOSTNAMES"] = ["dennisfink.me"] + form = URLForm(MultiDict({"url": "https://dennisfink.me/blog/example/"})) + with app.app_context(): + AllowedHostname()(form, form.url) + + +def test_allowed_hostname_rejects_unconfigured_hostname(app: Flask) -> None: + app.config["WEBMENTIONS_SSG_ALLOWED_HOSTNAMES"] = ["dennisfink.me"] + form = URLForm(MultiDict({"url": "https://example.com/post"})) + with app.app_context(), pytest.raises(ValidationError, match="Invalid input"): + AllowedHostname()(form, form.url) + + +def test_allowed_hostname_uses_custom_message(app: Flask) -> None: + app.config["WEBMENTIONS_SSG_ALLOWED_HOSTNAMES"] = ["dennisfink.me"] + form = URLForm(MultiDict({"url": "https://example.com/post"})) + with ( + app.app_context(), + pytest.raises(ValidationError, match="Hostname is not allowed"), + ): + AllowedHostname("Hostname is not allowed")(form, form.url) + + +def test_public_url_accepts_public_url(monkeypatch: pytest.MonkeyPatch) -> None: + is_public_url = Mock(return_value=True) + monkeypatch.setattr("webmentions_ssg.forms.validators.is_public_url", is_public_url) + form = URLForm(MultiDict({"url": "https://example.com/post"})) + PublicURL()(form, form.url) + is_public_url.assert_called_once_with("https://example.com/post") + + +@pytest.mark.parametrize( + "outcome", + [ + pytest.param(False, id="non-public-address"), + pytest.param( + AddressResolutionError("Could not resolve hostname"), id="resolution-error" + ), + pytest.param(ValueError("No hostname was specified"), id="missing-hostname"), + ], +) +def test_public_url_rejects_invalid_url( + monkeypatch: pytest.MonkeyPatch, outcome: bool | Exception +) -> None: + is_public_url = Mock() + + if isinstance(outcome, Exception): + is_public_url.side_effect = outcome + else: + is_public_url.return_value = outcome + + monkeypatch.setattr("webmentions_ssg.forms.validators.is_public_url", is_public_url) + form = URLForm(MultiDict({"url": "https://example.com/post"})) + + with pytest.raises(ValidationError, match="URL must resolve to a public address"): + PublicURL()(form, form.url) + + +def test_public_url_uses_custom_message(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "webmentions_ssg.forms.validators.is_public_url", Mock(return_value=False) + ) + form = URLForm(MultiDict({"url": "https://example.com/post"})) + + with pytest.raises(ValidationError, match="Public URL required"): + PublicURL("Public URL required")(form, form.url) |
