fix: stop push for expired device sessions (Closes #553)
CI / lint (pull_request) Successful in 1m8s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-11 07:54:56 +00:00
1 parent 1f05521094
commit 91e5b1e0c9
6 files changed
+152 -2

No files matched your search

+92 -1
View File
@@ -5,7 +5,7 @@ from types import SimpleNamespace
import pytest
from src import main
from src import dashboard_auth, main
from src.push_notifications import PushConfiguration, dispatch_unread_updates
from src.push_subscription_store import PushSubscriptionStore
@@ -55,6 +55,43 @@ def test_subscription_store_delivers_each_unread_thread_once_per_device(tmp_path
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 = {
@@ -217,6 +254,60 @@ async def test_timed_out_endpoint_does_not_stall_push_fanout(tmp_path):
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")
+14
View File
@@ -88,6 +88,20 @@ def test_session_status_reports_idle_without_background_validation_extending_act
assert store.status("phone-session", 3_000, idle_timeout_seconds=900) == "idle"
def test_managed_session_status_enforces_absolute_and_idle_expiry(tmp_path):
now = [1_000.0]
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
store.activate("phone-session", 3_000, management_id="phone-device")
store.activate("short-session", 1_200, management_id="short-device")
assert store.managed_status("phone-device", idle_timeout_seconds=900) == "active"
now[0] = 1_200.0
assert store.managed_status("short-device", idle_timeout_seconds=900) == "revoked"
now[0] = 1_900.0
assert store.managed_status("phone-device", idle_timeout_seconds=900) == "idle"
assert store.managed_status("missing-device", idle_timeout_seconds=900) == "revoked"
def test_touch_extends_only_the_matching_live_session(tmp_path):
now = [1_000.0]
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])