aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorDennis Fink2026-08-22 08:00:24 +0200
committerDennis Fink2026-08-22 08:00:24 +0200
commitc1270697d3846b1be445d19bd6490e24f8e1d504 (patch)
tree41aaac616c08047c398915772549136a546ccd12 /tests
parentc8d2c1e82cfbd9d3bd5a5d034e7daff9849834e9 (diff)
downloadwebmentions-ssg-c1270697d3846b1be445d19bd6490e24f8e1d504.tar.gz
webmentions-ssg-c1270697d3846b1be445d19bd6490e24f8e1d504.zip
fix(sender): correct resend redirect routeHEADmain
Use the sent source route's expected parameter name when redirecting after a manual Webmention resend. Expand view coverage for authentication, admin actions, status rendering, resends, rescans, and concurrent endpoint submissions.
Diffstat (limited to 'tests')
-rw-r--r--tests/test_views.py486
1 files changed, 485 insertions, 1 deletions
diff --git a/tests/test_views.py b/tests/test_views.py
index 844da2e..040e005 100644
--- a/tests/test_views.py
+++ b/tests/test_views.py
@@ -5,7 +5,7 @@
import uuid
from datetime import UTC, datetime
from types import ModuleType
-from unittest.mock import Mock, call
+from unittest.mock import ANY, Mock, call
import pytest
import sqlalchemy as sa
@@ -283,3 +283,487 @@ def test_sent_source_returns_404_for_unknown_source(
log_in(app, client)
response = client.get(f"/sent/{uuid.uuid7()}")
assert response.status_code == 404
+
+
+def mock_login_form(
+ views_module: ModuleType,
+ monkeypatch: pytest.MonkeyPatch,
+ *,
+ valid: bool,
+ username: str = "admin",
+ password: str = "test",
+) -> Mock:
+ form = Mock()
+ form.validate_on_submit.return_value = valid
+ form.username.data = username
+ form.password.data = password
+
+ monkeypatch.setattr(views_module.forms, "LoginForm", Mock(return_value=form))
+ return form
+
+
+def mock_admin_action_form(
+ views_module: ModuleType, monkeypatch: pytest.MonkeyPatch, *, valid: bool
+) -> Mock:
+ form = Mock()
+ form.validate_on_submit.return_value = valid
+
+ monkeypatch.setattr(views_module.forms, "AdminActionForm", Mock(return_value=form))
+ return form
+
+
+def create_received_webmention(
+ app: Flask,
+ *,
+ source: str = SOURCE_URL,
+ target: str = TARGET_URL,
+ status: str = "verified",
+ failure_reason: str | None = None,
+) -> uuid.UUID:
+ with app.app_context():
+ webmention = ReceivedWebmention(
+ uuid=uuid.uuid7(),
+ source=source,
+ target=target,
+ status=status,
+ failure_reason=failure_reason,
+ )
+
+ db.session.add(webmention)
+ db.session.commit()
+
+ return webmention.uuid
+
+
+def create_resendable_sent_webmention(app: Flask) -> tuple[uuid.UUID, uuid.UUID]:
+ now = datetime.now(UTC)
+
+ with app.app_context():
+ source = Source(
+ path="blog/resend/index.html",
+ url="https://dennisfink.me/blog/resend/",
+ content_hash="1" * 32,
+ revision=3,
+ last_seen_at=now,
+ revised_at=now,
+ )
+ webmention = SentWebmention(
+ target="https://example.com/resend",
+ active=True,
+ desired_revision=3,
+ processed_revision=3,
+ sent_revision=2,
+ status=SentWebmentionStatus.FAILED,
+ failure_reason="Previous failure",
+ endpoint="https://example.com/webmention",
+ response_status=500,
+ status_url="https://example.com/webmention/status/123",
+ )
+ source.sent_webmentions.append(webmention)
+
+ db.session.add(source)
+ db.session.commit()
+
+ return source.uuid, webmention.uuid
+
+
+def test_login_renders_form(client: FlaskClient) -> None:
+ response = client.get("/login")
+
+ assert response.status_code == 200
+ assert "login" in response.get_data(as_text=True).lower()
+
+
+def test_login_rejects_invalid_credentials(
+ app: Flask,
+ client: FlaskClient,
+ views_module: ModuleType,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ with app.app_context():
+ db.session.add(User(username="admin", password="test"))
+ db.session.commit()
+
+ mock_login_form(
+ views_module,
+ monkeypatch,
+ valid=True,
+ username="admin",
+ password="wrong-password",
+ )
+
+ response = client.post("/login")
+
+ assert response.status_code == 302
+ assert response.headers["Location"].endswith("/login")
+
+ with client.session_transaction() as session:
+ assert "_user_id" not in session
+ assert ("danger", "Invalid username or password") in session["_flashes"]
+
+
+def test_login_redirects_to_local_next_page(
+ app: Flask,
+ client: FlaskClient,
+ views_module: ModuleType,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ with app.app_context():
+ user = User(username="admin", password="test")
+ db.session.add(user)
+ db.session.commit()
+ user_id = user.id
+
+ mock_login_form(views_module, monkeypatch, valid=True)
+
+ response = client.post("/login?next=/received?page=2")
+
+ assert response.status_code == 302
+ assert response.headers["Location"].endswith("/received?page=2")
+
+ with client.session_transaction() as session:
+ assert session["_user_id"] == str(user_id)
+
+
+def test_login_rejects_external_next_page(
+ app: Flask,
+ client: FlaskClient,
+ views_module: ModuleType,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ with app.app_context():
+ db.session.add(User(username="admin", password="test"))
+ db.session.commit()
+
+ mock_login_form(views_module, monkeypatch, valid=True)
+
+ response = client.post("/login?next=https://evil.example/phishing")
+
+ assert response.status_code == 302
+ assert response.headers["Location"].endswith("/")
+ assert "evil.example" not in response.headers["Location"]
+
+
+def test_login_redirects_authenticated_user(app: Flask, client: FlaskClient) -> None:
+ log_in(app, client)
+
+ response = client.get("/login")
+
+ assert response.status_code == 302
+ assert response.headers["Location"].endswith("/")
+
+
+def test_logout_ends_session(app: Flask, client: FlaskClient) -> None:
+ log_in(app, client)
+
+ response = client.get("/logout")
+
+ assert response.status_code == 302
+ assert response.headers["Location"].endswith("/")
+
+ protected_response = client.get("/received")
+ assert protected_response.status_code == 302
+ assert "/login" in protected_response.headers["Location"]
+
+
+def test_received_requires_login(client: FlaskClient) -> None:
+ response = client.get("/received")
+
+ assert response.status_code == 302
+ assert "/login" in response.headers["Location"]
+
+
+def test_received_lists_webmentions(app: Flask, client: FlaskClient) -> None:
+ log_in(app, client)
+ create_received_webmention(app)
+
+ response = client.get("/received")
+
+ assert response.status_code == 200
+ html = response.get_data(as_text=True)
+ assert SOURCE_URL in html
+ assert TARGET_URL in html
+
+
+@pytest.mark.parametrize("action", ["delete", "reverify"])
+def test_received_admin_actions_require_login(client: FlaskClient, action: str) -> None:
+ response = client.post(f"/received/{uuid.uuid7()}/{action}")
+
+ assert response.status_code == 302
+ assert "/login" in response.headers["Location"]
+
+
+@pytest.mark.parametrize("action", ["delete", "reverify"])
+def test_received_admin_actions_reject_invalid_form(
+ app: Flask,
+ client: FlaskClient,
+ views_module: ModuleType,
+ monkeypatch: pytest.MonkeyPatch,
+ action: str,
+) -> None:
+ log_in(app, client)
+ identifier = create_received_webmention(app)
+ form = mock_admin_action_form(views_module, monkeypatch, valid=False)
+
+ response = client.post(f"/received/{identifier}/{action}")
+
+ assert response.status_code == 400
+ form.validate_on_submit.assert_called_once_with()
+
+
+@pytest.mark.parametrize("action", ["delete", "reverify"])
+def test_received_admin_actions_return_404_for_unknown_webmention(
+ app: Flask,
+ client: FlaskClient,
+ views_module: ModuleType,
+ monkeypatch: pytest.MonkeyPatch,
+ action: str,
+) -> None:
+ log_in(app, client)
+ mock_admin_action_form(views_module, monkeypatch, valid=True)
+
+ response = client.post(f"/received/{uuid.uuid7()}/{action}")
+
+ assert response.status_code == 404
+
+
+def test_delete_received_webmention(
+ app: Flask,
+ client: FlaskClient,
+ views_module: ModuleType,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ log_in(app, client)
+ identifier = create_received_webmention(app)
+ mock_admin_action_form(views_module, monkeypatch, valid=True)
+
+ response = client.post(f"/received/{identifier}/delete?page=3")
+
+ assert response.status_code == 302
+ assert response.headers["Location"].endswith("/received?page=3")
+
+ with app.app_context():
+ assert db.session.get(ReceivedWebmention, identifier) is None
+
+
+def test_reverify_received_webmention(
+ app: Flask,
+ client: FlaskClient,
+ views_module: ModuleType,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ log_in(app, client)
+ identifier = create_received_webmention(
+ app, status="failed", failure_reason="Previous failure"
+ )
+ mock_admin_action_form(views_module, monkeypatch, valid=True)
+ verify_webmention = Mock()
+ monkeypatch.setattr(views_module, "verify_webmention", verify_webmention)
+
+ response = client.post(f"/received/{identifier}/reverify?page=2")
+
+ assert response.status_code == 302
+ assert response.headers["Location"].endswith("/received?page=2")
+
+ with app.app_context():
+ webmention = db.session.get(ReceivedWebmention, identifier)
+ assert webmention is not None
+ assert webmention.status == "received"
+ assert webmention.failure_reason is None
+
+ verify_webmention.assert_called_once_with(identifier)
+
+
+def test_rescan_sent_sources_requires_login(client: FlaskClient) -> None:
+ response = client.post("/sent/rescan")
+
+ assert response.status_code == 302
+ assert "/login" in response.headers["Location"]
+
+
+@pytest.mark.parametrize("valid", [False, True])
+def test_rescan_sent_sources(
+ app: Flask,
+ client: FlaskClient,
+ views_module: ModuleType,
+ monkeypatch: pytest.MonkeyPatch,
+ valid: bool,
+) -> None:
+ log_in(app, client)
+ form = mock_admin_action_form(views_module, monkeypatch, valid=valid)
+ manual_scan_sources = Mock()
+ monkeypatch.setattr(views_module, "manual_scan_sources", manual_scan_sources)
+
+ response = client.post("/sent/rescan")
+
+ assert response.status_code == 302
+ assert response.headers["Location"].endswith("/sent")
+ form.validate_on_submit.assert_called_once_with()
+
+ if valid:
+ manual_scan_sources.assert_called_once_with()
+ else:
+ manual_scan_sources.assert_not_called()
+
+
+def test_resend_sent_webmention_requires_login(client: FlaskClient) -> None:
+ response = client.post(f"/sent/{uuid.uuid7()}/{uuid.uuid7()}/resend")
+
+ assert response.status_code == 302
+ assert "/login" in response.headers["Location"]
+
+
+def test_resend_sent_webmention_rejects_invalid_form(
+ app: Flask,
+ client: FlaskClient,
+ views_module: ModuleType,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ log_in(app, client)
+ source_identifier, webmention_identifier = create_resendable_sent_webmention(app)
+ mock_admin_action_form(views_module, monkeypatch, valid=False)
+
+ response = client.post(f"/sent/{source_identifier}/{webmention_identifier}/resend")
+
+ assert response.status_code == 400
+
+
+def test_resend_sent_webmention_returns_404_for_unknown_webmention(
+ app: Flask,
+ client: FlaskClient,
+ views_module: ModuleType,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ log_in(app, client)
+ source_identifier = create_sent_source(app)
+ mock_admin_action_form(views_module, monkeypatch, valid=True)
+
+ response = client.post(f"/sent/{source_identifier}/{uuid.uuid7()}/resend")
+
+ assert response.status_code == 404
+
+
+def test_resend_sent_webmention_rejects_wrong_source(
+ app: Flask,
+ client: FlaskClient,
+ views_module: ModuleType,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ log_in(app, client)
+ _, webmention_identifier = create_resendable_sent_webmention(app)
+ wrong_source_identifier = create_sent_source(app)
+ mock_admin_action_form(views_module, monkeypatch, valid=True)
+
+ response = client.post(
+ f"/sent/{wrong_source_identifier}/{webmention_identifier}/resend"
+ )
+
+ assert response.status_code == 404
+
+
+def test_resend_sent_webmention_reopens_current_revision(
+ app: Flask,
+ client: FlaskClient,
+ views_module: ModuleType,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ log_in(app, client)
+ source_identifier, webmention_identifier = create_resendable_sent_webmention(app)
+ mock_admin_action_form(views_module, monkeypatch, valid=True)
+ send_webmention = Mock()
+ monkeypatch.setattr(views_module, "send_webmention", send_webmention)
+
+ response = client.post(f"/sent/{source_identifier}/{webmention_identifier}/resend")
+
+ assert response.status_code == 302
+ assert response.headers["Location"].endswith(f"/sent/{source_identifier}")
+
+ with app.app_context():
+ webmention = db.session.get(SentWebmention, webmention_identifier)
+ assert webmention is not None
+ assert webmention.desired_revision == 3
+ assert webmention.processed_revision is None
+ assert webmention.sent_revision == 2
+ assert webmention.status is None
+ assert webmention.failure_reason is None
+ assert webmention.endpoint is None
+ assert webmention.response_status is None
+ assert webmention.status_url is None
+
+ send_webmention.assert_called_once_with(webmention_identifier)
+
+
+def test_status_returns_404_for_unknown_webmention(client: FlaskClient) -> None:
+ response = client.get(f"/status/{uuid.uuid7()}")
+
+ assert response.status_code == 404
+
+
+def test_status_displays_webmention(app: Flask, client: FlaskClient) -> None:
+ identifier = create_received_webmention(
+ app, status="failed", failure_reason="Verification failed"
+ )
+
+ response = client.get(f"/status/{identifier}")
+
+ assert response.status_code == 200
+ html = response.get_data(as_text=True)
+ assert SOURCE_URL in html
+ assert TARGET_URL in html
+ assert "failed" in html
+ assert "Verification failed" in html
+
+
+def test_endpoint_recovers_from_concurrent_insert(
+ client: FlaskClient, views_module: ModuleType, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ mock_endpoint_form(views_module, monkeypatch, valid=True)
+
+ competing_identifier = uuid.uuid7()
+ competing_webmention = ReceivedWebmention(
+ uuid=competing_identifier,
+ source=SOURCE_URL,
+ target=TARGET_URL,
+ status="failed",
+ failure_reason="Competing request failed",
+ )
+
+ initial_result = Mock()
+ initial_result.scalar_one_or_none.return_value = None
+ competing_result = Mock()
+ competing_result.scalar_one.return_value = competing_webmention
+
+ execute = Mock(side_effect=[initial_result, competing_result])
+ add = Mock()
+ rollback = Mock()
+ commit = Mock(
+ side_effect=[
+ sa.exc.IntegrityError(
+ "INSERT INTO received_webmention ...",
+ {},
+ Exception("duplicate source/target"),
+ ),
+ None,
+ ]
+ )
+ verify_webmention = Mock()
+
+ monkeypatch.setattr(views_module.db.session, "execute", execute)
+ monkeypatch.setattr(views_module.db.session, "add", add)
+ monkeypatch.setattr(views_module.db.session, "rollback", rollback)
+ monkeypatch.setattr(views_module.db.session, "commit", commit)
+ monkeypatch.setattr(views_module, "verify_webmention", verify_webmention)
+
+ response = client.post("/endpoint")
+
+ assert response.status_code == 201
+ assert response.headers["Location"].endswith(f"/status/{competing_identifier}")
+
+ assert execute.call_count == 2
+ add.assert_called_once_with(ANY)
+ rollback.assert_called_once_with()
+ assert commit.call_count == 2
+
+ assert competing_webmention.status == "received"
+ assert competing_webmention.failure_reason is None
+ verify_webmention.assert_called_once_with(competing_identifier)