1903 lines
69 KiB
Python
1903 lines
69 KiB
Python
import json
|
|
import asyncio
|
|
import os
|
|
import sqlite3
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from types import SimpleNamespace
|
|
|
|
import httpx
|
|
import pytest
|
|
import requests
|
|
|
|
from src import dashboard_auth, gitea_proxy, main
|
|
from src.push_notifications import (
|
|
PushConfiguration,
|
|
dispatch_following_changes,
|
|
dispatch_unread_updates,
|
|
send_web_push,
|
|
)
|
|
from src import push_subscription_store as push_store_module
|
|
from src.push_subscription_store import PushSubscriptionStore
|
|
from src.push_endpoint_policy import UnsafePushEndpoint
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def resolve_test_push_endpoints_publicly(monkeypatch):
|
|
async def accept(endpoint):
|
|
return endpoint
|
|
|
|
monkeypatch.setattr(main, "validate_public_push_endpoint", accept)
|
|
|
|
|
|
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_following_alert_preferences_are_opt_in_and_checkpoint_each_device(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"},
|
|
})
|
|
|
|
assert store.following_preferences("session-a") == {"enabled": False}
|
|
store.set_following_preferences("session-a", enabled=True)
|
|
|
|
devices = store.following_notification_devices()
|
|
assert [(device.session_id, device.delivered_fingerprint) for device in devices] == [
|
|
("session-a", None)
|
|
]
|
|
store.mark_following_delivered("session-a", "revision-fingerprint")
|
|
assert store.following_notification_devices()[0].delivered_fingerprint == "revision-fingerprint"
|
|
assert store.following_preferences("session-b") == {"enabled": False}
|
|
|
|
|
|
def test_quiet_hours_hold_unread_revisions_then_mark_one_catch_up_delivery(tmp_path):
|
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
|
store.upsert("session-a", {
|
|
"endpoint": "https://push.example/session-a",
|
|
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
|
})
|
|
store.set_quiet_hours(
|
|
"session-a", enabled=True, start="22:00", end="07:00", timezone="UTC"
|
|
)
|
|
|
|
inside = datetime(2026, 8, 25, 23, 0, tzinfo=timezone.utc).timestamp()
|
|
after = datetime(2026, 8, 26, 7, 1, tzinfo=timezone.utc).timestamp()
|
|
|
|
assert store.quiet_hours("session-a") == {
|
|
"enabled": True, "start": "22:00", "end": "07:00", "timezone": "UTC"
|
|
}
|
|
assert store.claim_unseen({42: "r1"}, now=inside) == []
|
|
delivery = store.claim_unseen({42: "r1"}, now=after)
|
|
assert len(delivery) == 1
|
|
assert delivery[0].thread_revisions == ((42, "r1"),)
|
|
assert delivery[0].catch_up is True
|
|
store.mark_delivered("session-a", delivery[0].thread_revisions)
|
|
assert store.claim_unseen({42: "r1"}, now=after) == []
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_unread_dispatch_sends_one_catch_up_digest_after_quiet_hours(tmp_path):
|
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
|
store.upsert("session-a", {
|
|
"endpoint": "https://push.example/session-a",
|
|
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
|
})
|
|
store.set_quiet_hours(
|
|
"session-a", enabled=True, start="22:00", end="07:00", timezone="UTC"
|
|
)
|
|
inside = datetime(2026, 8, 25, 23, 0, tzinfo=timezone.utc).timestamp()
|
|
after = datetime(2026, 8, 26, 7, 1, tzinfo=timezone.utc).timestamp()
|
|
assert store.claim_unseen({41: "r1", 42: "r1"}, now=inside) == []
|
|
sent = []
|
|
|
|
async def unread():
|
|
return {"items": [
|
|
{"id": 41, "updated_at": "r1"},
|
|
{"id": 42, "updated_at": "r1"},
|
|
]}
|
|
|
|
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, now=after) == 1
|
|
assert sent == [{
|
|
"title": "2 updates while alerts were paused",
|
|
"body": "Open Updates to catch up in Stackchain.",
|
|
"route": "#/my-work/updates",
|
|
"tag": "stackchain-update-catch-up",
|
|
"update_count": 2,
|
|
"unread_count": 2,
|
|
}]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_following_dispatch_is_private_deduplicated_and_session_bound(tmp_path):
|
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
|
for session_id in ("active", "revoked", "disabled"):
|
|
store.upsert(session_id, {
|
|
"endpoint": f"https://push.example/{session_id}",
|
|
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
|
})
|
|
store.set_following_preferences("active", enabled=True)
|
|
store.set_following_preferences("revoked", enabled=True)
|
|
sent = []
|
|
|
|
async def following():
|
|
return {"items": [{
|
|
"repository": "secret/project", "kind": "issue", "number": 42,
|
|
"title": "Sensitive acquisition", "updated_at": "2026-08-23T17:00:00Z",
|
|
"has_unseen_change": True,
|
|
}]}
|
|
|
|
async def send(subscription, payload):
|
|
sent.append((subscription["endpoint"], json.loads(payload)))
|
|
|
|
async def statuses(session_ids):
|
|
return {session_id: ("active" if session_id == "active" else "revoked") for session_id in session_ids}
|
|
|
|
configuration = PushConfiguration("public", "private", "mailto:ops@example.com")
|
|
assert await dispatch_following_changes(
|
|
store, configuration, following, send, session_statuses=statuses
|
|
) == 1
|
|
assert sent[0][0].endswith("/active")
|
|
assert sent[0][1] == {
|
|
"title": "1 watched item changed",
|
|
"body": "Open Following to review the latest activity.",
|
|
"route": "#/my-work/following",
|
|
"tag": sent[0][1]["tag"],
|
|
"following_count": 1,
|
|
}
|
|
assert sent[0][1]["tag"].startswith("stackchain-following-")
|
|
assert "secret" not in json.dumps(sent[0][1]).lower()
|
|
assert "acquisition" not in json.dumps(sent[0][1]).lower()
|
|
assert await dispatch_following_changes(
|
|
store, configuration, following, send, session_statuses=statuses
|
|
) == 0
|
|
assert len(sent) == 1
|
|
assert store.subscription_for_session("revoked") is None
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_following_changes_wait_for_quiet_hours_and_resume_as_catch_up(tmp_path):
|
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
|
store.upsert("session-a", {
|
|
"endpoint": "https://push.example/session-a",
|
|
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
|
})
|
|
store.set_following_preferences("session-a", enabled=True)
|
|
store.set_quiet_hours(
|
|
"session-a", enabled=True, start="22:00", end="07:00", timezone="UTC"
|
|
)
|
|
inside = datetime(2026, 8, 25, 23, 0, tzinfo=timezone.utc).timestamp()
|
|
after = datetime(2026, 8, 26, 7, 1, tzinfo=timezone.utc).timestamp()
|
|
sent = []
|
|
|
|
async def following():
|
|
return {"items": [{
|
|
"repository": "private/project", "kind": "issue", "number": 42,
|
|
"title": "Sensitive", "updated_at": "2026-08-25T23:00:00Z",
|
|
"has_unseen_change": True,
|
|
}]}
|
|
|
|
async def send(_subscription, payload):
|
|
sent.append(json.loads(payload))
|
|
|
|
configuration = PushConfiguration("public", "private", "mailto:ops@example.com")
|
|
assert await dispatch_following_changes(
|
|
store, configuration, following, send, now=inside
|
|
) == 0
|
|
assert sent == []
|
|
assert await dispatch_following_changes(
|
|
store, configuration, following, send, now=after
|
|
) == 1
|
|
assert sent[0]["title"] == "1 watched update while alerts were paused"
|
|
assert sent[0]["route"] == "#/my-work/following"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_authenticated_device_controls_following_alerts_independently(tmp_path, monkeypatch):
|
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
|
store.upsert("session-a", {
|
|
"endpoint": "https://push.example/session-a",
|
|
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
|
})
|
|
monkeypatch.setattr(main, "_push_subscription_store", store)
|
|
|
|
async def management_id(_session):
|
|
return "session-a"
|
|
|
|
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
|
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
|
|
|
|
result = await main.update_following_notifications(
|
|
main.FollowingNotificationPayload(enabled=True), request
|
|
)
|
|
|
|
assert result == {"following_enabled": True}
|
|
assert (await main.push_status(request))["following_enabled"] is True
|
|
assert store.deadline_preferences("session-a")["enabled"] is False
|
|
assert store.start_day_preferences("session-a")["enabled"] is False
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_authenticated_device_persists_validated_quiet_hours(tmp_path, monkeypatch):
|
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
|
store.upsert("session-a", {
|
|
"endpoint": "https://push.example/session-a",
|
|
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
|
})
|
|
monkeypatch.setattr(main, "_push_subscription_store", store)
|
|
|
|
async def management_id(_session):
|
|
return "session-a"
|
|
|
|
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
|
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
|
|
payload = main.QuietHoursPayload(
|
|
enabled=True, start="22:30", end="06:45", timezone="America/New_York"
|
|
)
|
|
|
|
result = await main.update_quiet_hours(payload, request)
|
|
|
|
assert result == {
|
|
"quiet_hours_enabled": True,
|
|
"quiet_hours_start": "22:30",
|
|
"quiet_hours_end": "06:45",
|
|
"quiet_hours_timezone": "America/New_York",
|
|
}
|
|
status = await main.push_status(request)
|
|
assert {key: status[key] for key in result} == result
|
|
|
|
|
|
def test_quiet_hours_reject_equal_boundaries_and_invalid_timezone():
|
|
with pytest.raises(ValueError):
|
|
main.QuietHoursPayload(enabled=True, start="22:00", end="22:00", timezone="UTC")
|
|
with pytest.raises(ValueError):
|
|
main.QuietHoursPayload(enabled=True, start="22:00", end="07:00", timezone="Moon/Base")
|
|
|
|
|
|
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_encrypts_device_credentials_and_reopens_with_same_key(tmp_path):
|
|
database = tmp_path / "push.sqlite3"
|
|
key = b"p" * 32
|
|
subscription = {
|
|
"endpoint": "https://push.example/private-device-canary",
|
|
"keys": {"p256dh": "public-key-canary", "auth": "auth-secret-canary"},
|
|
}
|
|
|
|
PushSubscriptionStore(database, encryption_key=key).upsert("session-a", subscription)
|
|
|
|
persisted = database.read_bytes()
|
|
assert b"private-device-canary" not in persisted
|
|
assert b"public-key-canary" not in persisted
|
|
assert b"auth-secret-canary" not in persisted
|
|
reopened = PushSubscriptionStore(database, encryption_key=key)
|
|
assert reopened.claim_unseen({42: "r1"})[0].subscription == subscription
|
|
|
|
|
|
def test_subscription_store_migrates_legacy_plaintext_without_resetting_checkpoints(tmp_path):
|
|
database = tmp_path / "push.sqlite3"
|
|
subscription = {
|
|
"endpoint": "https://push.example/legacy-device-canary",
|
|
"keys": {"p256dh": "legacy-public-canary", "auth": "legacy-auth-canary"},
|
|
}
|
|
with sqlite3.connect(database) as connection:
|
|
connection.executescript(
|
|
"""
|
|
CREATE TABLE push_subscriptions (
|
|
session_id TEXT PRIMARY KEY,
|
|
endpoint TEXT NOT NULL UNIQUE,
|
|
subscription_json TEXT NOT NULL
|
|
);
|
|
CREATE TABLE push_deliveries (
|
|
session_id TEXT NOT NULL,
|
|
thread_id INTEGER NOT NULL,
|
|
revision TEXT NOT NULL DEFAULT '',
|
|
PRIMARY KEY (session_id, thread_id)
|
|
);
|
|
"""
|
|
)
|
|
connection.execute(
|
|
"INSERT INTO push_subscriptions VALUES (?, ?, ?)",
|
|
("session-a", subscription["endpoint"], json.dumps(subscription)),
|
|
)
|
|
connection.execute(
|
|
"INSERT INTO push_deliveries VALUES (?, ?, ?)",
|
|
("session-a", 42, "r1"),
|
|
)
|
|
|
|
store = PushSubscriptionStore(database, encryption_key=b"m" * 32)
|
|
|
|
assert store.claim_unseen({42: "r1"}) == []
|
|
updated = store.claim_unseen({42: "r2"})
|
|
assert updated[0].subscription == subscription
|
|
persisted = database.read_bytes()
|
|
assert b"legacy-device-canary" not in persisted
|
|
assert b"legacy-public-canary" not in persisted
|
|
assert b"legacy-auth-canary" not in persisted
|
|
|
|
|
|
def test_push_store_factory_only_requires_its_key_when_push_is_enabled(
|
|
tmp_path, monkeypatch
|
|
):
|
|
monkeypatch.delenv("STACKCHAIN_PUSH_STATE_ENCRYPTION_KEY", raising=False)
|
|
factory = getattr(push_store_module, "build_push_subscription_store", None)
|
|
assert callable(factory), "push store factory is missing"
|
|
|
|
disabled = factory(tmp_path / "disabled.sqlite3", push_enabled=False)
|
|
assert disabled.is_subscribed("session-a") is False
|
|
assert disabled.deadline_preferences("session-a")["enabled"] is False
|
|
assert disabled.claim_unseen({42: "r1"}, now=0) == []
|
|
assert not (tmp_path / "disabled.sqlite3").exists()
|
|
with pytest.raises(RuntimeError, match="encryption key"):
|
|
factory(tmp_path / "enabled.sqlite3", push_enabled=True)
|
|
|
|
|
|
def test_subscription_store_fails_closed_for_wrong_key_or_tampered_payload(tmp_path):
|
|
database = tmp_path / "push.sqlite3"
|
|
subscription = {
|
|
"endpoint": "https://push.example/device-a",
|
|
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
|
}
|
|
PushSubscriptionStore(database, encryption_key=b"a" * 32).upsert(
|
|
"session-a", subscription
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="could not be decrypted"):
|
|
PushSubscriptionStore(database, encryption_key=b"b" * 32)
|
|
|
|
with sqlite3.connect(database) as connection:
|
|
payload = connection.execute(
|
|
"SELECT subscription_json FROM push_subscriptions WHERE session_id = ?",
|
|
("session-a",),
|
|
).fetchone()[0]
|
|
replacement = "A" if payload[-1] != "A" else "B"
|
|
connection.execute(
|
|
"UPDATE push_subscriptions SET subscription_json = ? WHERE session_id = ?",
|
|
(payload[:-1] + replacement, "session-a"),
|
|
)
|
|
with pytest.raises(RuntimeError, match="could not be decrypted"):
|
|
PushSubscriptionStore(database, encryption_key=b"a" * 32)
|
|
|
|
|
|
def test_subscription_ciphertext_cannot_be_substituted_between_sessions(tmp_path):
|
|
database = tmp_path / "push.sqlite3"
|
|
key = b"s" * 32
|
|
store = PushSubscriptionStore(database, encryption_key=key)
|
|
for session_id in ("session-a", "session-b"):
|
|
store.upsert(
|
|
session_id,
|
|
{
|
|
"endpoint": f"https://push.example/{session_id}",
|
|
"keys": {"p256dh": f"key-{session_id}", "auth": f"auth-{session_id}"},
|
|
},
|
|
)
|
|
with sqlite3.connect(database) as connection:
|
|
payloads = connection.execute(
|
|
"SELECT session_id, subscription_json FROM push_subscriptions ORDER BY session_id"
|
|
).fetchall()
|
|
connection.executemany(
|
|
"UPDATE push_subscriptions SET subscription_json = ? WHERE session_id = ?",
|
|
((payloads[1][1], payloads[0][0]), (payloads[0][1], payloads[1][0])),
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="could not be decrypted"):
|
|
PushSubscriptionStore(database, encryption_key=key)
|
|
|
|
|
|
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 == []
|
|
|
|
|
|
def test_delivery_health_is_bounded_per_channel_and_recovers_after_success(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"},
|
|
})
|
|
|
|
store.mark_delivery_failed("session-a", "unread", "timeout", now=100)
|
|
store.mark_delivery_failed("session-a", "unread", "provider", now=110)
|
|
store.mark_delivery_failed("session-a", "deadline", "provider", now=120)
|
|
|
|
assert store.delivery_health("session-a") == {
|
|
"unread": {
|
|
"state": "degraded",
|
|
"consecutive_failures": 2,
|
|
"last_attempted_at": 110,
|
|
"last_succeeded_at": None,
|
|
"reason": "provider",
|
|
},
|
|
"deadline": {
|
|
"state": "degraded",
|
|
"consecutive_failures": 1,
|
|
"last_attempted_at": 120,
|
|
"last_succeeded_at": None,
|
|
"reason": "provider",
|
|
},
|
|
}
|
|
|
|
store.mark_delivery_succeeded("session-a", "unread", now=130)
|
|
|
|
assert store.delivery_health("session-a")["unread"] == {
|
|
"state": "healthy",
|
|
"consecutive_failures": 0,
|
|
"last_attempted_at": 130,
|
|
"last_succeeded_at": 130,
|
|
"reason": None,
|
|
}
|
|
assert store.delivery_health("session-a")["deadline"]["state"] == "degraded"
|
|
|
|
|
|
def test_subscription_store_reopens_a_delivered_thread_when_its_revision_changes(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"},
|
|
})
|
|
|
|
first = store.claim_unseen({42: "2026-08-11T12:00:00Z"})
|
|
store.mark_delivered("session-a", first[0].thread_revisions)
|
|
unchanged = store.claim_unseen({42: "2026-08-11T12:00:00Z"})
|
|
updated = store.claim_unseen({42: "2026-08-11T12:05:00Z"})
|
|
|
|
assert unchanged == []
|
|
assert updated[0].thread_revisions == ((42, "2026-08-11T12:05:00Z"),)
|
|
|
|
|
|
def test_subscription_store_does_not_realert_for_an_older_snapshot_revision(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"},
|
|
})
|
|
store.mark_delivered("session-a", {42: "2026-08-11T12:05:00Z"})
|
|
|
|
assert store.claim_unseen({42: "2026-08-11T12:00:00Z"}) == []
|
|
|
|
|
|
def test_complete_unread_snapshot_reconciles_delivery_and_digest_checkpoints(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"},
|
|
})
|
|
revisions = {1: "r1", 2: "r2", 3: "r3"}
|
|
store.mark_delivered("session-a", revisions)
|
|
store.mark_digest_pending("session-a", {3: "r3"})
|
|
|
|
store.reconcile_unread({2})
|
|
delivery = store.claim_unseen(revisions)[0]
|
|
|
|
assert delivery.thread_ids == (1, 3)
|
|
assert delivery.digest_ids == ()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_dispatch_reconciles_stale_checkpoints_after_a_complete_snapshot(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"},
|
|
})
|
|
store.mark_delivered("session-a", {1: "r1"})
|
|
|
|
async def unread():
|
|
return {"items": [{"id": 2, "updated_at": "r2"}], "complete": True}
|
|
|
|
async def send(_subscription, _payload):
|
|
return None
|
|
|
|
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
|
assert await dispatch_unread_updates(store, config, unread, send) == 1
|
|
|
|
remaining = store.claim_unseen({1: "r1", 2: "r2"})
|
|
assert remaining[0].thread_ids == (1,)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_dispatch_sends_nothing_and_preserves_checkpoints_for_an_incomplete_snapshot(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"},
|
|
})
|
|
store.mark_delivered("session-a", {1: "r1"})
|
|
sent = []
|
|
|
|
async def unread():
|
|
return {"items": [{"id": 2, "updated_at": "r2"}], "complete": False}
|
|
|
|
async def send(_subscription, payload):
|
|
sent.append(payload)
|
|
|
|
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
|
assert await dispatch_unread_updates(store, config, unread, send) == 0
|
|
assert sent == []
|
|
assert store.claim_unseen({1: "r1"}) == []
|
|
|
|
|
|
def test_subscription_store_migrates_legacy_delivery_without_replaying_it(tmp_path):
|
|
path = tmp_path / "push.sqlite3"
|
|
subscription = {
|
|
"endpoint": "https://push.example/device-a",
|
|
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
|
}
|
|
with sqlite3.connect(path) as connection:
|
|
connection.executescript("""
|
|
CREATE TABLE push_subscriptions (
|
|
session_id TEXT PRIMARY KEY,
|
|
endpoint TEXT NOT NULL UNIQUE,
|
|
subscription_json TEXT NOT NULL
|
|
);
|
|
CREATE TABLE push_deliveries (
|
|
session_id TEXT NOT NULL,
|
|
thread_id INTEGER NOT NULL,
|
|
PRIMARY KEY (session_id, thread_id)
|
|
);
|
|
""")
|
|
connection.execute(
|
|
"INSERT INTO push_subscriptions VALUES (?, ?, ?)",
|
|
("session-a", subscription["endpoint"], json.dumps(subscription)),
|
|
)
|
|
connection.execute("INSERT INTO push_deliveries VALUES (?, ?)", ("session-a", 42))
|
|
|
|
store = PushSubscriptionStore(path)
|
|
|
|
assert store.claim_unseen({42: "2026-08-11T12:00:00Z"}) == []
|
|
updated = store.claim_unseen({42: "2026-08-11T12:05:00Z"})
|
|
assert updated[0].thread_revisions == ((42, "2026-08-11T12:05:00Z"),)
|
|
|
|
|
|
@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_managed_session_statuses_apply_configured_idle_deadline_in_one_call(monkeypatch):
|
|
calls = []
|
|
|
|
class Store:
|
|
def managed_statuses(self, management_ids, *, idle_timeout_seconds):
|
|
calls.append((list(management_ids), idle_timeout_seconds))
|
|
return {management_id: "active" for management_id in set(management_ids)}
|
|
|
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS", "321")
|
|
monkeypatch.setattr(dashboard_auth, "_session_store", lambda: Store())
|
|
|
|
assert await dashboard_auth.managed_session_statuses(
|
|
["device-b", "device-a", "device-b"]
|
|
) == {"device-a": "active", "device-b": "active"}
|
|
assert calls == [(["device-b", "device-a", "device-b"], 321)]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_managed_session_statuses_bind_batch_to_expected_principal(monkeypatch):
|
|
calls = []
|
|
|
|
class Store:
|
|
def managed_statuses(
|
|
self, management_ids, *, idle_timeout_seconds, expected_principal_id
|
|
):
|
|
calls.append(
|
|
(list(management_ids), idle_timeout_seconds, expected_principal_id)
|
|
)
|
|
return {management_id: "active" for management_id in set(management_ids)}
|
|
|
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS", "321")
|
|
monkeypatch.setattr(dashboard_auth, "_session_store", lambda: Store())
|
|
|
|
assert await dashboard_auth.managed_session_statuses(
|
|
["device-b", "device-a"], expected_principal_id=42
|
|
) == {"device-a": "active", "device-b": "active"}
|
|
assert calls == [(["device-b", "device-a"], 321, 42)]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_push_session_statuses_use_current_upstream_identity(monkeypatch):
|
|
calls = []
|
|
|
|
async def current_user():
|
|
return {"id": 42, "login": "timmy"}
|
|
|
|
async def statuses(management_ids, *, expected_principal_id):
|
|
calls.append((list(management_ids), expected_principal_id))
|
|
return {management_id: "active" for management_id in management_ids}
|
|
|
|
monkeypatch.setattr(main.gitea_proxy, "current_user", current_user)
|
|
monkeypatch.setattr(main.dashboard_auth, "managed_session_statuses", statuses)
|
|
|
|
assert await main._identity_bound_push_session_statuses(["phone-device"]) == {
|
|
"phone-device": "active"
|
|
}
|
|
assert calls == [(["phone-device"], 42)]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_push_session_statuses_fail_closed_when_identity_is_invalid(monkeypatch):
|
|
async def current_user():
|
|
return {"id": "not-an-integer", "login": "timmy"}
|
|
|
|
monkeypatch.setattr(main.gitea_proxy, "current_user", current_user)
|
|
|
|
with pytest.raises(ValueError, match="identity"):
|
|
await main._identity_bound_push_session_statuses(["phone-device"])
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_push_poll_authorizes_delivery_against_managed_session(monkeypatch):
|
|
captured = {}
|
|
hold_other_channels = asyncio.Event()
|
|
|
|
async def no_wait(_seconds):
|
|
return None
|
|
|
|
async def stop_after_capture(*args, **kwargs):
|
|
captured["unread"] = args[2]
|
|
captured.update(kwargs)
|
|
raise asyncio.CancelledError
|
|
|
|
async def hold_dispatch(*_args, **_kwargs):
|
|
await hold_other_channels.wait()
|
|
|
|
monkeypatch.setenv("STACKCHAIN_PUSH_MAX_CONCURRENCY", "3")
|
|
monkeypatch.setenv("STACKCHAIN_PUSH_MAX_INDIVIDUAL_NOTIFICATIONS", "4")
|
|
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
|
|
monkeypatch.setattr(main, "dispatch_unread_updates", stop_after_capture)
|
|
monkeypatch.setattr(main, "dispatch_following_changes", hold_dispatch)
|
|
monkeypatch.setattr(main, "dispatch_deadline_reminders", hold_dispatch)
|
|
monkeypatch.setattr(main, "dispatch_start_day_reminders", hold_dispatch)
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await main._push_poll_loop()
|
|
|
|
assert captured["unread"] is main.gitea_proxy.unread_notification_snapshot
|
|
assert captured["session_statuses"] is main._identity_bound_push_session_statuses
|
|
assert captured["max_concurrency"] == 3
|
|
assert captured["max_individual_notifications"] == 4
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_push_poll_still_dispatches_deadlines_when_unread_dispatch_fails(monkeypatch):
|
|
calls = []
|
|
|
|
sleeps = 0
|
|
first_tick = asyncio.Event()
|
|
|
|
async def no_wait(_seconds):
|
|
nonlocal sleeps
|
|
sleeps += 1
|
|
if sleeps <= 4:
|
|
if sleeps == 4:
|
|
first_tick.set()
|
|
await first_tick.wait()
|
|
else:
|
|
raise asyncio.CancelledError
|
|
|
|
async def fail_unread(*_args, **_kwargs):
|
|
calls.append("unread")
|
|
raise RuntimeError("unread unavailable")
|
|
|
|
async def dispatch_deadlines(*_args, **_kwargs):
|
|
calls.append("deadline")
|
|
|
|
async def dispatch_start_day(*_args, **_kwargs):
|
|
calls.append("start-day")
|
|
|
|
async def dispatch_following(*_args, **_kwargs):
|
|
calls.append("following")
|
|
|
|
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
|
|
monkeypatch.setattr(main, "dispatch_unread_updates", fail_unread)
|
|
monkeypatch.setattr(main, "dispatch_following_changes", dispatch_following)
|
|
monkeypatch.setattr(main, "dispatch_deadline_reminders", dispatch_deadlines)
|
|
monkeypatch.setattr(main, "dispatch_start_day_reminders", dispatch_start_day)
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await main._push_poll_loop()
|
|
|
|
assert sorted(calls) == ["deadline", "following", "start-day", "unread"]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_push_poll_deadlines_continue_while_unread_dispatch_is_blocked(monkeypatch):
|
|
unread_started = asyncio.Event()
|
|
release_unread = asyncio.Event()
|
|
deadline_started = asyncio.Event()
|
|
|
|
async def no_wait(_seconds):
|
|
return None
|
|
|
|
async def blocked_unread(*_args, **_kwargs):
|
|
unread_started.set()
|
|
await release_unread.wait()
|
|
|
|
async def dispatch_deadlines(*_args, **_kwargs):
|
|
deadline_started.set()
|
|
await release_unread.wait()
|
|
|
|
async def dispatch_start_day(*_args, **_kwargs):
|
|
await release_unread.wait()
|
|
|
|
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
|
|
monkeypatch.setattr(main, "dispatch_unread_updates", blocked_unread)
|
|
monkeypatch.setattr(main, "dispatch_following_changes", dispatch_start_day)
|
|
monkeypatch.setattr(main, "dispatch_deadline_reminders", dispatch_deadlines)
|
|
monkeypatch.setattr(main, "dispatch_start_day_reminders", dispatch_start_day)
|
|
|
|
poll = asyncio.create_task(main._push_poll_loop())
|
|
await asyncio.wait_for(unread_started.wait(), timeout=0.5)
|
|
try:
|
|
await asyncio.wait_for(deadline_started.wait(), timeout=0.5)
|
|
finally:
|
|
poll.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await poll
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_push_poll_uses_a_lower_independent_deadline_cadence(monkeypatch):
|
|
intervals = []
|
|
|
|
async def capture_channel(_dispatch, *, interval):
|
|
intervals.append(interval)
|
|
|
|
monkeypatch.setenv("STACKCHAIN_PUSH_POLL_SECONDS", "30")
|
|
monkeypatch.setenv("STACKCHAIN_DEADLINE_POLL_SECONDS", "600")
|
|
monkeypatch.setattr(main, "_push_channel_loop", capture_channel)
|
|
|
|
await main._push_poll_loop()
|
|
|
|
assert sorted(intervals) == [30.0, 30.0, 600.0, 600.0]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_push_channel_loop_skips_missed_ticks_without_overlapping(monkeypatch):
|
|
starts = []
|
|
now = 0.0
|
|
|
|
class Loop:
|
|
def time(self):
|
|
return now
|
|
|
|
async def record_dispatch():
|
|
nonlocal now
|
|
starts.append("dispatch")
|
|
now = 16.0 if len(starts) == 1 else now
|
|
if len(starts) == 2:
|
|
raise asyncio.CancelledError
|
|
|
|
sleeps = []
|
|
|
|
async def capture_sleep(seconds):
|
|
nonlocal now
|
|
sleeps.append(seconds)
|
|
now += seconds
|
|
|
|
monkeypatch.setattr(main.asyncio, "get_running_loop", lambda: Loop())
|
|
monkeypatch.setattr(main.asyncio, "sleep", capture_sleep)
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await main._push_channel_loop(record_dispatch, interval=5.0)
|
|
|
|
assert starts == ["dispatch", "dispatch"]
|
|
assert sleeps == [5.0, 4.0]
|
|
|
|
|
|
def test_dispatch_leases_are_isolated_by_channel(tmp_path):
|
|
path = tmp_path / "push.sqlite3"
|
|
first = PushSubscriptionStore(path)
|
|
second = PushSubscriptionStore(path)
|
|
|
|
assert first.acquire_dispatch_lease(
|
|
"unread-owner", channel="unread", now=100, lease_seconds=30
|
|
) is True
|
|
assert second.acquire_dispatch_lease(
|
|
"deadline-owner", channel="deadline", now=100, lease_seconds=30
|
|
) is True
|
|
assert second.acquire_dispatch_lease(
|
|
"other-unread-owner", channel="unread", now=100, lease_seconds=30
|
|
) is False
|
|
|
|
|
|
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,
|
|
"unread_count": 1,
|
|
}
|
|
assert "private/repo" not in json.dumps(sent)
|
|
assert "Secret title" not in json.dumps(sent)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_dispatch_realerts_when_the_same_thread_has_a_newer_revision(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"},
|
|
})
|
|
revision = "2026-08-11T12:00:00Z"
|
|
sent = []
|
|
|
|
async def unread():
|
|
return {"items": [{
|
|
"id": 42,
|
|
"updated_at": revision,
|
|
"repository": "private/repo",
|
|
"title": "Secret follow-up",
|
|
}]}
|
|
|
|
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 await dispatch_unread_updates(store, config, unread, send) == 0
|
|
revision = "2026-08-11T12:05:00Z"
|
|
assert await dispatch_unread_updates(store, config, unread, send) == 1
|
|
assert [payload["notification_id"] for payload in sent] == [42, 42]
|
|
assert "updated_at" not in json.dumps(sent)
|
|
assert "Secret follow-up" not in json.dumps(sent)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_update_burst_sends_bounded_individual_pushes_and_one_private_digest(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": notification_id,
|
|
"repository": "private/repo",
|
|
"title": f"Secret update {notification_id}",
|
|
}
|
|
for notification_id in range(41, 46)
|
|
]}
|
|
|
|
async def send(_subscription, payload):
|
|
sent.append(json.loads(payload))
|
|
|
|
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
|
delivered = await dispatch_unread_updates(
|
|
store,
|
|
config,
|
|
unread,
|
|
send,
|
|
max_individual_notifications=2,
|
|
)
|
|
|
|
assert delivered == 3
|
|
assert [payload["tag"] for payload in sent] == [
|
|
"stackchain-update-41",
|
|
"stackchain-update-42",
|
|
"stackchain-update-digest",
|
|
]
|
|
assert sent[-1] == {
|
|
"title": "3 new work updates",
|
|
"body": "Tap to review them in Stackchain.",
|
|
"route": "#/my-work/updates",
|
|
"tag": "stackchain-update-digest",
|
|
"update_count": 3,
|
|
"unread_count": 5,
|
|
}
|
|
assert [payload["unread_count"] for payload in sent] == [5, 5, 5]
|
|
assert "private/repo" not in json.dumps(sent)
|
|
assert "Secret update" not in json.dumps(sent)
|
|
assert await dispatch_unread_updates(
|
|
store,
|
|
config,
|
|
unread,
|
|
send,
|
|
max_individual_notifications=2,
|
|
) == 0
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_failed_digest_retries_only_overflow_after_individual_checkpoints(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"},
|
|
})
|
|
attempts = []
|
|
|
|
async def unread():
|
|
return {"items": [{"id": notification_id} for notification_id in range(51, 56)]}
|
|
|
|
async def fail_digest(_subscription, payload):
|
|
decoded = json.loads(payload)
|
|
attempts.append(decoded["tag"])
|
|
if decoded["tag"] == "stackchain-update-digest":
|
|
raise RuntimeError("push provider unavailable")
|
|
|
|
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
|
assert await dispatch_unread_updates(
|
|
store,
|
|
config,
|
|
unread,
|
|
fail_digest,
|
|
max_individual_notifications=2,
|
|
) == 2
|
|
assert store.claim_unseen(range(51, 56))[0].thread_ids == (53, 54, 55)
|
|
|
|
async def succeed(_subscription, payload):
|
|
attempts.append(json.loads(payload)["tag"])
|
|
|
|
assert await dispatch_unread_updates(
|
|
store,
|
|
config,
|
|
unread,
|
|
succeed,
|
|
max_individual_notifications=2,
|
|
) == 1
|
|
assert attempts == [
|
|
"stackchain-update-51",
|
|
"stackchain-update-52",
|
|
"stackchain-update-digest",
|
|
"stackchain-update-digest",
|
|
]
|
|
assert store.claim_unseen(range(51, 56)) == []
|
|
|
|
|
|
@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,
|
|
"unread_count": 1,
|
|
}]
|
|
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_dispatch_removes_an_endpoint_that_rebinds_private_without_contacting_it(tmp_path):
|
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
|
store.upsert("unsafe-device", {
|
|
"endpoint": "https://push.example/rebound",
|
|
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
|
})
|
|
contacted = []
|
|
|
|
async def unread():
|
|
return {"items": [{"id": 8}]}
|
|
|
|
async def reject_rebound(_endpoint):
|
|
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service")
|
|
|
|
async def send(subscription, _payload):
|
|
contacted.append(subscription["endpoint"])
|
|
|
|
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
|
assert await dispatch_unread_updates(
|
|
store, config, unread, send, endpoint_validator=reject_rebound
|
|
) == 0
|
|
assert contacted == []
|
|
assert store.is_subscribed("unsafe-device") is False
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_web_push_transport_dials_the_address_validated_for_the_original_tls_host():
|
|
endpoint = "https://push.example/device-a"
|
|
resolved = SimpleNamespace(
|
|
endpoint=endpoint,
|
|
hostname="push.example",
|
|
port=443,
|
|
addresses=("93.184.216.34",),
|
|
)
|
|
resolutions = []
|
|
connection = {}
|
|
|
|
async def resolve_once(value):
|
|
resolutions.append(value)
|
|
return resolved
|
|
|
|
def inspect_transport(**kwargs):
|
|
session = kwargs["requests_session"]
|
|
assert session.trust_env is False
|
|
request = session.prepare_request(requests.Request("POST", endpoint))
|
|
adapter = session.get_adapter(endpoint)
|
|
adapter.add_headers(request)
|
|
host, tls = adapter.build_connection_pool_key_attributes(request, True)
|
|
connection.update(host=host, tls=tls, headers=dict(request.headers))
|
|
|
|
await send_web_push(
|
|
{"endpoint": endpoint, "keys": {"p256dh": "key", "auth": "secret"}},
|
|
"{}",
|
|
PushConfiguration("public", "private", "mailto:ops@example.com"),
|
|
endpoint_resolver=resolve_once,
|
|
webpush_sender=inspect_transport,
|
|
)
|
|
|
|
assert resolutions == [endpoint]
|
|
assert connection["host"] == {
|
|
"scheme": "https",
|
|
"host": "93.184.216.34",
|
|
"port": 443,
|
|
}
|
|
assert connection["tls"]["assert_hostname"] == "push.example"
|
|
assert connection["tls"]["server_hostname"] == "push.example"
|
|
assert connection["headers"]["Host"] == "push.example"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_connect_time_rebinding_rejection_removes_only_the_unsafe_device(tmp_path):
|
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
|
for session_id in ("unsafe-device", "healthy-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": 8}]}
|
|
|
|
async def send(subscription, _payload):
|
|
if subscription["endpoint"].endswith("unsafe-device"):
|
|
raise UnsafePushEndpoint("Endpoint rebound before connection")
|
|
sent.append(subscription["endpoint"])
|
|
|
|
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
|
assert await dispatch_unread_updates(store, config, unread, send) == 1
|
|
assert store.is_subscribed("unsafe-device") is False
|
|
assert store.is_subscribed("healthy-device") is True
|
|
assert sent == ["https://push.example/healthy-device"]
|
|
|
|
|
|
@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_unread_dispatch_records_degraded_health_then_recovers(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"},
|
|
})
|
|
revision = 0
|
|
|
|
async def unread():
|
|
nonlocal revision
|
|
revision += 1
|
|
return {"items": [{"id": 42, "updated_at": f"r{revision}"}], "complete": True}
|
|
|
|
async def timeout_send(_subscription, _payload):
|
|
raise asyncio.TimeoutError
|
|
|
|
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
|
for _ in range(3):
|
|
assert await dispatch_unread_updates(store, config, unread, timeout_send) == 0
|
|
|
|
health = store.delivery_health("session-a")["unread"]
|
|
assert health["state"] == "degraded"
|
|
assert health["consecutive_failures"] == 3
|
|
assert health["reason"] == "timeout"
|
|
|
|
async def succeed(_subscription, _payload):
|
|
return None
|
|
|
|
assert await dispatch_unread_updates(store, config, unread, succeed) == 1
|
|
recovered = store.delivery_health("session-a")["unread"]
|
|
assert recovered["state"] == "healthy"
|
|
assert recovered["consecutive_failures"] == 0
|
|
assert recovered["last_succeeded_at"] is not None
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_unexpected_device_failure_waits_for_siblings_and_retries_only_that_device(tmp_path):
|
|
path = tmp_path / "push.sqlite3"
|
|
store = PushSubscriptionStore(path)
|
|
competitor = PushSubscriptionStore(path)
|
|
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"},
|
|
})
|
|
healthy_started = asyncio.Event()
|
|
release_healthy = asyncio.Event()
|
|
sent = []
|
|
|
|
async def unread():
|
|
return {"items": [{"id": 8, "updated_at": "r1"}], "complete": True}
|
|
|
|
async def validate(endpoint):
|
|
if endpoint.endswith("failing-device"):
|
|
raise TimeoutError("endpoint validation unavailable")
|
|
return endpoint
|
|
|
|
async def send(subscription, _payload):
|
|
sent.append(subscription["endpoint"])
|
|
healthy_started.set()
|
|
await release_healthy.wait()
|
|
|
|
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
|
dispatch = asyncio.create_task(dispatch_unread_updates(
|
|
store,
|
|
config,
|
|
unread,
|
|
send,
|
|
endpoint_validator=validate,
|
|
))
|
|
await asyncio.wait_for(healthy_started.wait(), timeout=0.2)
|
|
await asyncio.sleep(0)
|
|
|
|
assert dispatch.done() is False
|
|
assert competitor.acquire_dispatch_lease(
|
|
"competing-worker", now=time.time(), lease_seconds=60
|
|
) is False
|
|
|
|
release_healthy.set()
|
|
assert await dispatch == 1
|
|
remaining = store.claim_unseen({8: "r1"})
|
|
assert [(item.session_id, item.thread_ids) for item in remaining] == [
|
|
("failing-device", (8,))
|
|
]
|
|
assert store.delivery_health("failing-device")["unread"]["reason"] == "timeout"
|
|
|
|
retried = []
|
|
|
|
async def validate_recovered(endpoint):
|
|
return endpoint
|
|
|
|
async def send_retry(subscription, _payload):
|
|
retried.append(subscription["endpoint"])
|
|
|
|
assert await dispatch_unread_updates(
|
|
store,
|
|
config,
|
|
unread,
|
|
send_retry,
|
|
endpoint_validator=validate_recovered,
|
|
) == 1
|
|
assert retried == ["https://push.example/failing-device"]
|
|
assert sent == ["https://push.example/healthy-device"]
|
|
|
|
|
|
@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}]}
|
|
|
|
authorization_calls = []
|
|
|
|
async def session_statuses(management_ids):
|
|
authorization_calls.append(list(management_ids))
|
|
return {
|
|
management_id: "active" if management_id == "active-device" else "revoked"
|
|
for management_id in management_ids
|
|
}
|
|
|
|
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_statuses=session_statuses
|
|
) == 1
|
|
assert store.is_subscribed("expired-device") is False
|
|
assert store.is_subscribed("active-device") is True
|
|
assert sent == ["https://push.example/active-device"]
|
|
assert authorization_calls == [["active-device", "expired-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_ids):
|
|
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_statuses=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(**_kwargs):
|
|
return {"items": [], "complete": True}
|
|
|
|
monkeypatch.setattr(main.gitea_proxy, "unread_notification_snapshot", 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",
|
|
"deadline_enabled": False,
|
|
"timezone": "UTC",
|
|
"reminder_hour": 9,
|
|
"reminder_days": 2,
|
|
"snoozed_until": None,
|
|
"start_day_enabled": False,
|
|
"start_day_timezone": "UTC",
|
|
"start_day_reminder_hour": 9,
|
|
"following_enabled": False,
|
|
"quiet_hours_enabled": False,
|
|
"quiet_hours_start": "22:00",
|
|
"quiet_hours_end": "07:00",
|
|
"quiet_hours_timezone": "UTC",
|
|
"delivery_health": {},
|
|
}
|
|
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_authenticated_device_can_send_a_real_test_notification(tmp_path, monkeypatch):
|
|
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)
|
|
monkeypatch.setattr(main, "_push_subscription_store", store)
|
|
configuration = PushConfiguration("public", "private", "mailto:ops@example.com")
|
|
monkeypatch.setattr(main, "_push_configuration", lambda: configuration)
|
|
|
|
async def management_id(_session):
|
|
return "session-a"
|
|
|
|
sent = []
|
|
|
|
async def send(delivery, payload, config):
|
|
sent.append((delivery, json.loads(payload), config))
|
|
|
|
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
|
monkeypatch.setattr(main, "send_web_push", send, raising=False)
|
|
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
|
|
|
|
assert await main.test_push_notification(request) == {
|
|
"delivery_state": "healthy",
|
|
"delivered": True,
|
|
}
|
|
assert sent == [(
|
|
subscription,
|
|
{
|
|
"title": "Stackchain notifications are working",
|
|
"body": "This device can receive private work alerts.",
|
|
"route": "#/device-setup",
|
|
"tag": "stackchain-push-test",
|
|
},
|
|
configuration,
|
|
)]
|
|
assert store.delivery_health("session-a")["unread"]["state"] == "healthy"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_test_notification_reports_provider_failure_as_degraded(tmp_path, monkeypatch):
|
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
|
store.upsert("session-a", {
|
|
"endpoint": "https://push.example/device-a",
|
|
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
|
})
|
|
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 "session-a"
|
|
|
|
async def fail(*_args):
|
|
raise asyncio.TimeoutError
|
|
|
|
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
|
monkeypatch.setattr(main, "send_web_push", fail)
|
|
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
|
|
|
|
with pytest.raises(main.HTTPException) as raised:
|
|
await main.test_push_notification(request)
|
|
|
|
assert raised.value.status_code == 503
|
|
assert raised.value.detail == "Test notification delivery failed"
|
|
assert store.is_subscribed("session-a") is True
|
|
assert store.delivery_health("session-a")["unread"]["reason"] == "timeout"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_authenticated_device_can_enable_deadline_reminders(tmp_path, monkeypatch):
|
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
|
store.upsert("session-a", {
|
|
"endpoint": "https://push.example/device-a",
|
|
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
|
})
|
|
monkeypatch.setattr(main, "_push_subscription_store", store)
|
|
|
|
async def management_id(_session):
|
|
return "session-a"
|
|
|
|
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
|
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
|
|
payload = main.DeadlineReminderPayload(
|
|
enabled=True, timezone="America/New_York", reminder_hour=9, reminder_days=7
|
|
)
|
|
|
|
assert await main.update_deadline_reminders(payload, request) == {
|
|
"deadline_enabled": True,
|
|
"timezone": "America/New_York",
|
|
"reminder_hour": 9,
|
|
"reminder_days": 7,
|
|
}
|
|
assert store.deadline_preferences("session-a")["enabled"] is True
|
|
assert store.deadline_preferences("session-a")["reminder_days"] == 7
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_authenticated_device_can_snooze_its_enabled_deadline_reminder(tmp_path, monkeypatch):
|
|
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"},
|
|
})
|
|
store.set_deadline_preferences(
|
|
session_id, enabled=True, timezone="UTC", reminder_hour=9
|
|
)
|
|
monkeypatch.setattr(main, "_push_subscription_store", store)
|
|
monkeypatch.setattr(main.time, "time", lambda: 1_765_000_000)
|
|
|
|
async def management_id(_session):
|
|
return "session-a"
|
|
|
|
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
|
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
|
|
|
|
assert await main.snooze_deadline_reminder(request) == {
|
|
"snoozed": True,
|
|
"snoozed_until": 1_765_003_600,
|
|
}
|
|
devices = {device.session_id: device for device in store.deadline_reminder_devices()}
|
|
assert devices["session-a"].snoozed_until == 1_765_003_600
|
|
assert devices["session-b"].snoozed_until is None
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_push_status_exposes_only_the_current_devices_active_deadline_snooze(tmp_path, monkeypatch):
|
|
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"},
|
|
})
|
|
store.set_deadline_preferences(
|
|
session_id, enabled=True, timezone="UTC", reminder_hour=9
|
|
)
|
|
store.snooze_deadline_reminder("session-a", now=1_765_000_000)
|
|
store.snooze_deadline_reminder("session-b", now=1_765_001_000)
|
|
monkeypatch.setattr(main, "_push_subscription_store", store)
|
|
monkeypatch.setattr(main.time, "time", lambda: 1_765_000_100)
|
|
|
|
async def management_id(_session):
|
|
return "session-a"
|
|
|
|
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
|
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
|
|
|
|
status = await main.push_status(request)
|
|
|
|
assert status["snoozed_until"] == 1_765_003_600
|
|
assert store.deadline_preferences("session-a", now=1_765_004_000)["snoozed_until"] is None
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_authenticated_device_can_resume_only_its_own_deadline_reminders(tmp_path, monkeypatch):
|
|
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"},
|
|
})
|
|
store.set_deadline_preferences(
|
|
session_id, enabled=True, timezone="UTC", reminder_hour=9
|
|
)
|
|
store.snooze_deadline_reminder(session_id, now=1_765_000_000)
|
|
monkeypatch.setattr(main, "_push_subscription_store", store)
|
|
|
|
async def management_id(_session):
|
|
return "session-a"
|
|
|
|
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
|
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
|
|
|
|
assert await main.resume_deadline_reminders(request) == {"snoozed": False}
|
|
devices = {device.session_id: device for device in store.deadline_reminder_devices()}
|
|
assert devices["session-a"].snoozed_until is None
|
|
assert devices["session-b"].snoozed_until == 1_765_003_600
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_subscription_rejects_an_unsafe_endpoint_before_persistence(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 "unsafe-device"
|
|
|
|
async def reject_private(_endpoint):
|
|
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service")
|
|
|
|
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
|
monkeypatch.setattr(main, "validate_public_push_endpoint", reject_private)
|
|
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 rejected:
|
|
await main.subscribe_push(payload, request)
|
|
|
|
assert rejected.value.status_code == 422
|
|
assert rejected.value.detail == "Endpoint must resolve to a public Web Push service"
|
|
assert store.is_subscribed("unsafe-device") is False
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_subscription_baselines_every_revision_from_the_complete_snapshot(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"
|
|
|
|
revisions = {
|
|
thread_id: f"2026-08-11T12:{thread_id % 60:02d}:00Z"
|
|
for thread_id in range(1, 126)
|
|
}
|
|
|
|
async def complete_snapshot(**_kwargs):
|
|
return {
|
|
"items": [
|
|
{"id": thread_id, "updated_at": revision}
|
|
for thread_id, revision in revisions.items()
|
|
],
|
|
"total": 125,
|
|
"complete": True,
|
|
}
|
|
|
|
async def first_page_must_not_be_used():
|
|
raise AssertionError("subscription used the first-page inbox endpoint")
|
|
|
|
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
|
monkeypatch.setattr(main.gitea_proxy, "unread_notification_snapshot", complete_snapshot)
|
|
monkeypatch.setattr(main, "notifications", first_page_must_not_be_used)
|
|
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(revisions) == []
|
|
|
|
|
|
@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(**_kwargs):
|
|
return {"items": [{"id": 42}], "complete": True}
|
|
|
|
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
|
monkeypatch.setattr(main.gitea_proxy, "unread_notification_snapshot", 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(**_kwargs):
|
|
raise RuntimeError("Gitea unavailable")
|
|
|
|
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
|
monkeypatch.setattr(main.gitea_proxy, "unread_notification_snapshot", 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
|