diff --git a/README.md b/README.md index 57d009b..d0930a1 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,14 @@ Web Push requires these environment variables on the API server: The `pywebpush` dependency is included in `requirements.txt`. Users can configure notification categories, Vertretungsplan class scope, the active window, interval, timezone, and whether message previews contain the sender and subject in **Settings → Benachrichtigungen**. +Subscriptions, category baselines and pending delivery retries live in `data/auth.db`. Normal process restarts preserve them and restart the polling worker. Keep that directory persistent (the supplied Docker Compose file mounts a named volume), use a stable working directory for a service installation, and preserve the `JWT_SECRET`/credential encryption key and VAPID key pair across deployments. Replacing these files or keys is not an ordinary restart. + +An upgrade introducing per-login session ownership intentionally removes legacy subscriptions that cannot be safely attributed to a login. The browser must register its existing subscription again; updated frontends do this on authenticated page visits and periodically while visible. Do not retain unowned endpoints or attach them arbitrarily to another session to bypass that migration. + +Push-service acceptance is not a device delivery receipt. Messages are retained by the push service for up to 24 hours while a device is unavailable; time-sensitive plan updates for one hour, and test pushes for ten minutes. The library's default is zero seconds, which discards offline messages. Only explicit push-service 404/410 responses expire a stored endpoint. Temporary network, signing or server configuration failures preserve subscriptions for retry. + +Polling remains subject to each account's active time window, interval, unread-message status, class scope and live login session. A newly enabled category first records a baseline. A successful test push does not test these automatic polling conditions. Real push delivery and deployment configuration must be checked separately; regression tests use temporary databases and an isolated simulated push service. + ## WhatsApp assistant The hosted API can optionally expose a WhatsApp assistant. Users link diff --git a/api/message_notifications.py b/api/message_notifications.py index 9b195da..5e5a887 100644 --- a/api/message_notifications.py +++ b/api/message_notifications.py @@ -34,6 +34,9 @@ MAX_CONCURRENT_USER_POLLS = 4 CLOCK_PATTERN = re.compile(r"^\d{2}:\d{2}$") PERMANENT_PUSH_STATUS_CODES = frozenset({404, 410}) +MESSAGE_PUSH_TTL_SECONDS = 24 * 60 * 60 +PLAN_PUSH_TTL_SECONDS = 60 * 60 +TEST_PUSH_TTL_SECONDS = 10 * 60 TRUSTED_PUSH_ENDPOINT_HOSTS = frozenset( { "fcm.googleapis.com", @@ -395,7 +398,9 @@ def _is_permanent_push_failure(error: Exception) -> bool: status_code = getattr(response, "status_code", None) if status_code in PERMANENT_PUSH_STATUS_CODES: return True - return status_code is None and isinstance(error, (KeyError, TypeError, ValueError)) + # Local signing/configuration errors are not evidence that a browser's + # subscription expired. In particular a bad VAPID key must not erase devices. + return False def _apply_preview_preference( @@ -448,12 +453,21 @@ def _send_push_sync(subscription: Dict[str, Any], payload: Dict[str, Any]) -> No "pywebpush is not installed; install the API requirements to enable push notifications" ) from error + tag = str(payload.get("tag", "")) + # A push service accepting a message does not mean the device is online. + # pywebpush defaults to TTL=0, which silently discards offline deliveries. + ttl = MESSAGE_PUSH_TTL_SECONDS + if tag.startswith("lanis-vertretungsplan-"): + ttl = PLAN_PUSH_TTL_SECONDS + elif tag == "lanis-notification-test": + ttl = TEST_PUSH_TTL_SECONDS webpush( subscription_info=subscription, data=json.dumps(payload, ensure_ascii=False), vapid_private_key=os.environ["VAPID_PRIVATE_KEY"], vapid_claims={"sub": os.environ["VAPID_SUBJECT"]}, timeout=10, + ttl=ttl, ) diff --git a/tests/test_message_notifications.py b/tests/test_message_notifications.py index 685dc36..4edae8f 100644 --- a/tests/test_message_notifications.py +++ b/tests/test_message_notifications.py @@ -1,5 +1,8 @@ import asyncio import sys +import json +import subprocess +from pathlib import Path from datetime import datetime from types import SimpleNamespace from types import ModuleType @@ -11,7 +14,17 @@ from api import message_notifications as notifications -def test_push_delivery_uses_a_finite_timeout(monkeypatch): +@pytest.mark.parametrize( + "tag,ttl", + [ + ("lanis-messages-1", 86400), + ("lanis-vertretungsplan-1", 3600), + ("lanis-notification-test", 600), + ], +) +def test_push_delivery_retains_offline_messages_with_a_finite_timeout( + monkeypatch, tag, ttl +): calls = {} pywebpush = ModuleType("pywebpush") @@ -24,10 +37,167 @@ def webpush(**kwargs): monkeypatch.setenv("VAPID_SUBJECT", "mailto:test@example.org") notifications._send_push_sync( - {"endpoint": "https://push.example/subscription"}, {"title": "Test"} + {"endpoint": "https://push.example/subscription"}, {"title": "Test", "tag": tag} ) assert calls["timeout"] == 10 + assert calls["ttl"] == ttl + + +@pytest.mark.parametrize("status", [401, 403, 408, 429, 500, 503, None]) +def test_server_and_transient_errors_never_expire_browser_subscriptions(status): + error = ValueError("VAPID signing failed") + if status is not None: + error.response = SimpleNamespace(status_code=status) + assert notifications._is_permanent_push_failure(error) is False + + +@pytest.mark.parametrize("status", [404, 410]) +def test_push_service_expiration_remains_permanent(status): + error = RuntimeError("subscription expired") + error.response = SimpleNamespace(status_code=status) + assert notifications._is_permanent_push_failure(error) is True + + +def test_notification_registration_and_pending_delivery_survive_process_restart( + tmp_path, monkeypatch +): + database = tmp_path / "restart-auth.db" + monkeypatch.setattr(auth_db, "DB_PATH", str(database)) + subscription = { + "endpoint": "https://fcm.googleapis.com/example", + "keys": {"p256dh": "public", "auth": "secret"}, + } + pending = { + "checked_at": "2026-10-17T09:00:00+02:00", + "conversations": {"one": "seen"}, + "pending_deliveries": {subscription["endpoint"]: [{"title": "pending"}]}, + } + + async def setup(): + await auth_db.initialize() + token = await auth_db.store_refresh_token( + "restart-user", "5201", "user", "password" + ) + session = await auth_db.get_refresh_token(token) + await auth_db.save_notification_preferences("restart-user", {"enabled": True}) + await auth_db.save_push_subscription( + "restart-user", subscription, session["session_id"] + ) + await auth_db.save_message_notification_state("restart-user", pending) + await auth_db.save_vertretungsplan_notification_state( + "restart-user", {"entries": {"one": "seen"}} + ) + + asyncio.run(setup()) + script = """ +import asyncio, json, sys +from api import auth_db +auth_db.DB_PATH = sys.argv[1] +async def main(): + await auth_db.initialize() + print(json.dumps({ + 'subscriptions': await auth_db.get_push_subscriptions('restart-user'), + 'messages': await auth_db.get_message_notification_state('restart-user'), + 'plan': await auth_db.get_vertretungsplan_notification_state('restart-user'), + 'enabled': [u['user_id'] for u in await auth_db.get_enabled_notification_users()], + })) +asyncio.run(main()) +""" + result = subprocess.run( + [sys.executable, "-c", script, str(database)], + cwd=Path(__file__).resolve().parents[1], + capture_output=True, + text=True, + check=True, + ) + restored = json.loads(result.stdout) + assert restored == { + "subscriptions": [subscription], + "messages": pending, + "plan": {"entries": {"one": "seen"}}, + "enabled": ["restart-user"], + } + + +def test_real_webpush_encryption_sends_a_nonzero_ttl_header(monkeypatch): + import base64 + import os + + import requests + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import ec + + pywebpush = pytest.importorskip("pywebpush") + from py_vapid import Vapid + + signer = Vapid() + signer.generate_keys() + receiver = ec.generate_private_key(ec.SECP256R1()) + public = receiver.public_key().public_bytes( + serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint + ) + captured = {} + + class PushService: + def post(self, endpoint, **kwargs): + captured.update(kwargs) + response = requests.Response() + response.status_code = 201 + response._content = b"" + return response + + real_webpush = pywebpush.webpush + + def send(**kwargs): + kwargs["vapid_private_key"] = signer + kwargs["requests_session"] = PushService() + return real_webpush(**kwargs) + + monkeypatch.setattr(pywebpush, "webpush", send) + monkeypatch.setenv("VAPID_PRIVATE_KEY", "ephemeral-test-key") + monkeypatch.setenv("VAPID_SUBJECT", "mailto:test@example.org") + notifications._send_push_sync( + { + "endpoint": "https://fcm.googleapis.com/ephemeral-test", + "keys": { + "p256dh": base64.urlsafe_b64encode(public).decode().rstrip("="), + "auth": base64.urlsafe_b64encode(os.urandom(16)).decode().rstrip("="), + }, + }, + {"title": "Test", "tag": "lanis-messages-1"}, + ) + headers = {key.lower(): value for key, value in captured["headers"].items()} + assert int(headers["ttl"]) == 86400 + assert headers["content-encoding"] == "aes128gcm" + assert headers["authorization"].startswith("vapid ") + assert b'"title"' not in captured["data"] + + +def test_local_signing_failure_retains_devices_and_allows_a_retry(monkeypatch): + endpoint = "https://fcm.googleapis.com/signing-retry" + deleted = [] + failing = True + + async def send(*_args): + return ValueError("bad signing key") if failing else None + + async def delete(*args): + deleted.append(args) + + monkeypatch.setattr(notifications, "run_in_threadpool", send) + monkeypatch.setattr(notifications, "delete_push_subscription", delete) + monkeypatch.setattr(notifications, "push_configured", lambda: True) + + async def deliver(): + return await notifications._send_push_to_subscriptions( + "user", [{"endpoint": endpoint, "keys": {}}], {"title": "new message"} + ) + + assert asyncio.run(deliver()) == {endpoint: "retry"} + assert deleted == [] + failing = False + assert asyncio.run(deliver()) == {endpoint: "delivered"} def test_notification_storage_round_trip(tmp_path, monkeypatch):