stackchain-dashboard/tests/test_push_notifications.py
timmy 91e5b1e0c9
All checks were successful
CI / lint (pull_request) Successful in 1m8s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped
fix: stop push for expired device sessions (Closes #553)
2026-08-11 07:54:56 +00:00

374 lines
13 KiB
Python

import json
import asyncio
import os
from types import SimpleNamespace
import pytest
from src import dashboard_auth, 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.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
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": [
{"notification_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",
}
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": [{"notification_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": [{"notification_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": [{"notification_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_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": [{"notification_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_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": [{"notification_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": [{"notification_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_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