aboutsummaryrefslogtreecommitdiff
path: root/tests/forms
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--tests/forms/test_forms.py (renamed from tests/test_forms.py)69
-rw-r--r--tests/forms/test_validators.py114
2 files changed, 158 insertions, 25 deletions
diff --git a/tests/test_forms.py b/tests/forms/test_forms.py
index fc16858..6fe1849 100644
--- a/tests/test_forms.py
+++ b/tests/forms/test_forms.py
@@ -2,19 +2,25 @@
#
# SPDX-License-Identifier: BSD-3-Clause
-from unittest.mock import patch
+from unittest.mock import Mock
import pytest
from flask import Flask
from werkzeug.datastructures import MultiDict
-from webmentions_ssg.forms import EndpointForm
+from webmentions_ssg.forms import AdminActionForm, EndpointForm, LoginForm
VALID_SOURCE = "https://source.example/post"
VALID_TARGET = "https://dennisfink.me/blog/example/"
-@patch("webmentions_ssg.forms.validators.is_public_url", new=lambda url: True)
+@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"),
[
@@ -37,12 +43,6 @@ VALID_TARGET = "https://dennisfink.me/blog/example/"
id="source-scheme",
),
pytest.param(
- {"source": VALID_TARGET, "target": VALID_TARGET},
- "source",
- None,
- id="source-not-equal-to-target",
- ),
- pytest.param(
{"source": VALID_SOURCE},
"target",
"This field is required.",
@@ -60,31 +60,18 @@ VALID_TARGET = "https://dennisfink.me/blog/example/"
"target must begin with http or https",
id="target-scheme",
),
- pytest.param(
- {"source": VALID_SOURCE, "target": "https://example.com/post"},
- "target",
- None,
- id="target-allowed-hostname",
- ),
],
)
-def test_endpoint_form_rejects_invalid_data(
- app: Flask,
- form_data: dict[str, str],
- invalid_field: str,
- expected_error: str | None,
+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 invalid_field in form.errors
-
- if expected_error is not None:
- assert expected_error in form.errors[invalid_field]
+ assert expected_error in form.errors[invalid_field]
-@patch("webmentions_ssg.forms.validators.is_public_url", new=lambda url: True)
def test_endpoint_form_accepts_valid_data(app: Flask) -> None:
with app.test_request_context("/endpoint", method="POST"):
form = EndpointForm(
@@ -94,3 +81,35 @@ def test_endpoint_form_accepts_valid_data(app: Flask) -> None:
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)