import json import asyncio import os from types import SimpleNamespace import httpx import pytest from src import dashboard_auth, gitea_proxy, main from src.push_notifications import PushConfiguration, dispatch_unread_updates from src.push_subscription_store import PushSubscriptionStore def test_dispatch_lease_is_exclusive_recoverable_and_owner_fenced(tmp_path): path = tmp_path / "push.sqlite3" first = PushSubscriptionStore(path) second = PushSubscriptionStore(path) assert first.acquire_dispatch_lease("worker-a", now=100, lease_seconds=30) is True assert second.acquire_dispatch_lease("worker-b", now=100, lease_seconds=30) is False assert second.acquire_dispatch_lease("worker-b", now=131, lease_seconds=30) is True assert first.release_dispatch_lease("worker-a") is False assert second.release_dispatch_lease("worker-b") is True def test_subscription_store_uses_private_filesystem_permissions(tmp_path): state_dir = tmp_path / "push-state" previous_umask = os.umask(0) try: database = state_dir / "push.sqlite3" PushSubscriptionStore(database) finally: os.umask(previous_umask) assert state_dir.stat().st_mode & 0o777 == 0o700 assert database.stat().st_mode & 0o777 == 0o600 def test_subscription_store_delivers_each_unread_thread_once_per_device(tmp_path): store = PushSubscriptionStore(tmp_path / "push.sqlite3") subscription = { "endpoint": "https://push.example/device-a", "keys": {"p256dh": "public-key", "auth": "auth-secret"}, } store.upsert("session-a", subscription) first = store.claim_unseen({41, 42}) store.mark_delivered("session-a", first[0].thread_ids) second = store.claim_unseen({41, 42}) assert [(item.session_id, item.thread_ids) for item in first] == [ ("session-a", (41, 42)) ] assert second == [] @pytest.mark.anyio async def test_managed_session_active_applies_configured_idle_deadline(monkeypatch): calls = [] class Store: def managed_status(self, management_id, *, idle_timeout_seconds): calls.append((management_id, idle_timeout_seconds)) return "active" if management_id == "active-device" else "idle" monkeypatch.setenv("STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS", "321") monkeypatch.setattr(dashboard_auth, "_session_store", lambda: Store()) assert await dashboard_auth.managed_session_active("active-device") is True assert await dashboard_auth.managed_session_active("idle-device") is False assert calls == [("active-device", 321), ("idle-device", 321)] @pytest.mark.anyio async def test_push_poll_authorizes_delivery_against_managed_session(monkeypatch): captured = {} async def no_wait(_seconds): return None async def stop_after_capture(*_args, **kwargs): captured.update(kwargs) raise asyncio.CancelledError monkeypatch.setenv("STACKCHAIN_PUSH_MAX_CONCURRENCY", "3") monkeypatch.setattr(main.asyncio, "sleep", no_wait) monkeypatch.setattr(main, "dispatch_unread_updates", stop_after_capture) with pytest.raises(asyncio.CancelledError): await main._push_poll_loop() assert captured["session_active"] is dashboard_auth.managed_session_active assert captured["max_concurrency"] == 3 def test_replacing_subscription_resets_delivery_cursor_and_revocation_removes_device(tmp_path): store = PushSubscriptionStore(tmp_path / "push.sqlite3") original = { "endpoint": "https://push.example/original", "keys": {"p256dh": "old-key", "auth": "old-auth"}, } replacement = { "endpoint": "https://push.example/replacement", "keys": {"p256dh": "new-key", "auth": "new-auth"}, } store.upsert("session-a", original) store.claim_unseen({9}) store.upsert("session-a", replacement) delivery = store.claim_unseen({9}) store.delete_session("session-a") assert delivery[0].subscription == replacement assert delivery[0].thread_ids == (9,) assert store.claim_unseen({10}) == [] @pytest.mark.anyio async def test_dispatch_sends_one_privacy_safe_deep_link_per_new_thread(tmp_path): store = PushSubscriptionStore(tmp_path / "push.sqlite3") store.upsert("session-a", { "endpoint": "https://push.example/device-a", "keys": {"p256dh": "public-key", "auth": "auth-secret"}, }) sent = [] async def unread(): return {"items": [ {"id": 42, "repository": "private/repo", "title": "Secret title"}, ]} async def send(subscription, payload): sent.append((subscription, json.loads(payload))) config = PushConfiguration("public-vapid", "private-vapid", "mailto:ops@example.com") assert await dispatch_unread_updates(store, config, unread, send) == 1 assert await dispatch_unread_updates(store, config, unread, send) == 0 assert sent[0][1] == { "title": "New work update", "body": "Tap to review it in Stackchain.", "route": "#/my-work/update/42", "tag": "stackchain-update-42", "notification_id": 42, } assert "private/repo" not in json.dumps(sent) assert "Secret title" not in json.dumps(sent) @pytest.mark.anyio async def test_production_notification_page_dispatches_one_unread_push(tmp_path): def upstream(_request): return httpx.Response(200, json=[{ "id": 42, "unread": True, "repository": {"full_name": "private/repo"}, "subject": { "title": "Secret title", "type": "Issue", "html_url": "https://forge.example/private/repo/issues/7", }, }]) gitea_proxy.start_client(transport=httpx.MockTransport(upstream)) try: page = await gitea_proxy.notification_page(1) finally: await gitea_proxy.stop_client() store = PushSubscriptionStore(tmp_path / "push.sqlite3") store.upsert("session-a", { "endpoint": "https://push.example/device-a", "keys": {"p256dh": "public-key", "auth": "auth-secret"}, }) sent = [] async def unread(): return page async def send(_subscription, payload): sent.append(json.loads(payload)) config = PushConfiguration("public", "private", "mailto:ops@example.com") assert await dispatch_unread_updates(store, config, unread, send) == 1 assert sent == [{ "title": "New work update", "body": "Tap to review it in Stackchain.", "route": "#/my-work/update/42", "tag": "stackchain-update-42", "notification_id": 42, }] assert "private/repo" not in json.dumps(sent) assert "Secret title" not in json.dumps(sent) @pytest.mark.anyio async def test_concurrent_workers_do_not_dispatch_the_same_update(tmp_path): path = tmp_path / "push.sqlite3" first = PushSubscriptionStore(path) second = PushSubscriptionStore(path) first.upsert("session-a", { "endpoint": "https://push.example/device-a", "keys": {"p256dh": "public-key", "auth": "auth-secret"}, }) sending = asyncio.Event() finish = asyncio.Event() sent = [] async def unread(): return {"items": [{"id": 42}]} async def send(_subscription, payload): sent.append(json.loads(payload)["tag"]) sending.set() await finish.wait() config = PushConfiguration("public", "private", "mailto:ops@example.com") active = asyncio.create_task(dispatch_unread_updates(first, config, unread, send)) await sending.wait() competing = await asyncio.wait_for( dispatch_unread_updates(second, config, unread, send), timeout=0.1 ) finish.set() assert competing == 0 assert await active == 1 assert sent == ["stackchain-update-42"] @pytest.mark.anyio async def test_dispatch_removes_an_expired_push_endpoint(tmp_path): store = PushSubscriptionStore(tmp_path / "push.sqlite3") store.upsert("session-a", { "endpoint": "https://push.example/expired", "keys": {"p256dh": "public-key", "auth": "auth-secret"}, }) class Expired(Exception): response = SimpleNamespace(status_code=410) async def unread(): return {"items": [{"id": 8}]} async def send(_subscription, _payload): raise Expired() config = PushConfiguration("public", "private", "mailto:ops@example.com") assert await dispatch_unread_updates(store, config, unread, send) == 0 assert store.is_subscribed("session-a") is False @pytest.mark.anyio async def test_transient_endpoint_failure_does_not_block_healthy_devices(tmp_path): store = PushSubscriptionStore(tmp_path / "push.sqlite3") for session_id in ("session-a", "session-b"): store.upsert(session_id, { "endpoint": f"https://push.example/{session_id}", "keys": {"p256dh": "public-key", "auth": "auth-secret"}, }) sent = [] async def unread(): return {"items": [{"id": 8}]} async def send(subscription, _payload): if subscription["endpoint"].endswith("session-a"): raise RuntimeError("provider unavailable") sent.append(subscription["endpoint"]) config = PushConfiguration("public", "private", "mailto:ops@example.com") assert await dispatch_unread_updates(store, config, unread, send) == 1 assert sent == ["https://push.example/session-b"] assert store.claim_unseen({8})[0].session_id == "session-a" @pytest.mark.anyio async def test_transient_failure_stops_that_device_until_the_next_poll(tmp_path): store = PushSubscriptionStore(tmp_path / "push.sqlite3") for session_id in ("failing-device", "healthy-device"): store.upsert(session_id, { "endpoint": f"https://push.example/{session_id}", "keys": {"p256dh": "public-key", "auth": "auth-secret"}, }) attempts = [] async def unread(): return {"items": [ {"id": 8}, {"id": 9}, ]} async def send(subscription, payload): endpoint = subscription["endpoint"] thread_id = int(json.loads(payload)["route"].rsplit("/", 1)[1]) attempts.append((endpoint, thread_id)) if endpoint.endswith("failing-device"): raise RuntimeError("provider unavailable") config = PushConfiguration("public", "private", "mailto:ops@example.com") assert await dispatch_unread_updates(store, config, unread, send) == 2 assert [item for item in attempts if item[0].endswith("failing-device")] == [ ("https://push.example/failing-device", 8) ] remaining = {item.session_id: item.thread_ids for item in store.claim_unseen({8, 9})} assert remaining == {"failing-device": (8, 9)} @pytest.mark.anyio async def test_timed_out_endpoint_does_not_stall_push_fanout(tmp_path): store = PushSubscriptionStore(tmp_path / "push.sqlite3") for session_id in ("session-a", "session-b"): store.upsert(session_id, { "endpoint": f"https://push.example/{session_id}", "keys": {"p256dh": "public-key", "auth": "auth-secret"}, }) sent = [] async def unread(): return {"items": [{"id": 13}]} async def send(subscription, _payload): if subscription["endpoint"].endswith("session-a"): await asyncio.Event().wait() sent.append(subscription["endpoint"]) config = PushConfiguration("public", "private", "mailto:ops@example.com") delivered = await asyncio.wait_for( dispatch_unread_updates( store, config, unread, send, send_timeout_seconds=0.01 ), timeout=0.2, ) assert delivered == 1 assert sent == ["https://push.example/session-b"] @pytest.mark.anyio async def test_dispatches_devices_concurrently_with_a_strict_bound(tmp_path): store = PushSubscriptionStore(tmp_path / "push.sqlite3") for index in range(4): store.upsert(f"session-{index}", { "endpoint": f"https://push.example/session-{index}", "keys": {"p256dh": "public-key", "auth": "auth-secret"}, }) active = 0 peak = 0 bound_reached = asyncio.Event() release = asyncio.Event() async def unread(): return {"items": [{"id": 13}]} async def send(_subscription, _payload): nonlocal active, peak active += 1 peak = max(peak, active) if active == 2: bound_reached.set() await release.wait() active -= 1 config = PushConfiguration("public", "private", "mailto:ops@example.com") dispatch = asyncio.create_task(dispatch_unread_updates( store, config, unread, send, max_concurrency=2 )) await asyncio.wait_for(bound_reached.wait(), timeout=0.2) assert peak == 2 assert dispatch.done() is False release.set() assert await dispatch == 4 assert peak == 2 @pytest.mark.anyio async def test_dispatch_removes_inactive_sessions_without_blocking_active_devices(tmp_path): store = PushSubscriptionStore(tmp_path / "push.sqlite3") for session_id in ("expired-device", "active-device"): store.upsert(session_id, { "endpoint": f"https://push.example/{session_id}", "keys": {"p256dh": "public-key", "auth": "auth-secret"}, }) sent = [] async def unread(): return {"items": [{"id": 21}]} async def session_active(management_id): return management_id == "active-device" async def send(subscription, _payload): sent.append(subscription["endpoint"]) config = PushConfiguration("public", "private", "mailto:ops@example.com") assert await dispatch_unread_updates( store, config, unread, send, session_active=session_active ) == 1 assert store.is_subscribed("expired-device") is False assert store.is_subscribed("active-device") is True assert sent == ["https://push.example/active-device"] @pytest.mark.anyio async def test_dispatch_fails_closed_and_retains_subscriptions_when_session_registry_fails(tmp_path): store = PushSubscriptionStore(tmp_path / "push.sqlite3") store.upsert("active-device", { "endpoint": "https://push.example/active-device", "keys": {"p256dh": "public-key", "auth": "auth-secret"}, }) sent = [] async def unread(): return {"items": [{"id": 22}]} async def registry_unavailable(_management_id): raise RuntimeError("session registry unavailable") async def send(subscription, _payload): sent.append(subscription) config = PushConfiguration("public", "private", "mailto:ops@example.com") assert await dispatch_unread_updates( store, config, unread, send, session_active=registry_unavailable ) == 0 assert store.is_subscribed("active-device") is True assert sent == [] @pytest.mark.anyio async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(tmp_path, monkeypatch): store = PushSubscriptionStore(tmp_path / "push.sqlite3") monkeypatch.setattr(main, "_push_subscription_store", store) monkeypatch.setattr( main, "_push_configuration", lambda: PushConfiguration("public-vapid", "private-vapid", "mailto:ops@example.com"), ) async def management_id(_session): return "session-a" monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id) async def no_unread(): return {"items": []} monkeypatch.setattr(main, "notifications", no_unread) request = SimpleNamespace(state=SimpleNamespace( dashboard_session=SimpleNamespace(session_id="session-a") )) payload = main.PushSubscriptionPayload( endpoint="https://push.example/device-a", keys={"p256dh": "public-key", "auth": "auth-secret"}, ) assert await main.push_status(request) == { "available": True, "subscribed": False, "public_key": "public-vapid" } assert await main.subscribe_push(payload, request) == {"subscribed": True} assert (await main.push_status(request))["subscribed"] is True assert await main.unsubscribe_push(request) == {"subscribed": False} assert (await main.push_status(request))["subscribed"] is False @pytest.mark.anyio async def test_subscription_baselines_production_notification_ids(tmp_path, monkeypatch): store = PushSubscriptionStore(tmp_path / "push.sqlite3") monkeypatch.setattr(main, "_push_subscription_store", store) monkeypatch.setattr( main, "_push_configuration", lambda: PushConfiguration("public", "private", "mailto:ops@example.com"), ) async def management_id(_session): return "device-a" async def current_unread(): return {"items": [{"id": 42}]} monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id) monkeypatch.setattr(main, "notifications", current_unread) request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object())) payload = main.PushSubscriptionPayload( endpoint="https://push.example/device-a", keys={"p256dh": "public-key", "auth": "auth-secret"}, ) assert await main.subscribe_push(payload, request) == {"subscribed": True} assert store.claim_unseen({42}) == [] assert store.claim_unseen({42, 43})[0].thread_ids == (43,) @pytest.mark.anyio async def test_subscription_fails_closed_when_existing_unread_baseline_is_unavailable(tmp_path, monkeypatch): store = PushSubscriptionStore(tmp_path / "push.sqlite3") monkeypatch.setattr(main, "_push_subscription_store", store) monkeypatch.setattr( main, "_push_configuration", lambda: PushConfiguration("public", "private", "mailto:ops@example.com"), ) async def management_id(_session): return "device-a" async def unavailable(): raise RuntimeError("Gitea unavailable") monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id) monkeypatch.setattr(main, "notifications", unavailable) request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object())) payload = main.PushSubscriptionPayload( endpoint="https://push.example/device-a", keys={"p256dh": "public-key", "auth": "auth-secret"}, ) with pytest.raises(main.HTTPException) as raised: await main.subscribe_push(payload, request) assert raised.value.status_code == 503 assert store.is_subscribed("device-a") is False