Private
Public Access
The pywebpush version bump alone didn't fix WNS: even the latest release (2.4.0) has no WNS-specific header handling in its own source, confirmed by inspecting the installed package directly. Adds the required X-WNS-Cache-Policy header ourselves via webpush()'s own headers= param, gated to *.notify.windows.com endpoints. Also: subscribeToPush()'s permission request and service-worker-ready wait had no timeout, so a browser that never settles either (seen live on a fresh Windows/Edge install -- greyed out, no prompt, no error) left the toggle stuck forever with no feedback. Both now time out after 20s with an actionable message instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
333 lines
13 KiB
Python
333 lines
13 KiB
Python
import uuid
|
|
|
|
from pywebpush import WebPushException
|
|
from sqlalchemy import select
|
|
|
|
from app.models import PushSubscription
|
|
from tests.conftest import login_as, register_and_login
|
|
|
|
|
|
def _subscription_payload(suffix: str = "a") -> dict:
|
|
return {
|
|
"endpoint": f"https://push.example.com/ep-{suffix}",
|
|
"keys": {"p256dh": f"p256dh-{suffix}", "auth": f"auth-{suffix}"},
|
|
}
|
|
|
|
|
|
async def test_vapid_public_key_endpoint(client, db_session):
|
|
await register_and_login(client, db_session, username="alice")
|
|
resp = await client.get("/api/push/vapid-public-key")
|
|
assert resp.status_code == 200
|
|
assert "public_key" in resp.json()
|
|
|
|
|
|
def _unique_suffix() -> str:
|
|
return uuid.uuid4().hex[:8]
|
|
|
|
|
|
async def test_subscribe_creates_row(client, db_session):
|
|
await register_and_login(client, db_session, username="alice")
|
|
payload = _subscription_payload(_unique_suffix())
|
|
resp = await client.post("/api/push/subscribe", json=payload)
|
|
assert resp.status_code == 204
|
|
|
|
# The ds_chat_test database is shared across the whole suite and the
|
|
# ws_client-based tests below intentionally don't roll back (see
|
|
# conftest.ws_client), so a unique endpoint keeps this test independent
|
|
# of leftover rows from those instead of asserting on the total count.
|
|
result = await db_session.execute(
|
|
select(PushSubscription).where(PushSubscription.endpoint == payload["endpoint"])
|
|
)
|
|
rows = result.scalars().all()
|
|
assert len(rows) == 1
|
|
|
|
|
|
async def test_subscribe_upserts_by_endpoint(client, db_session):
|
|
await register_and_login(client, db_session, username="alice")
|
|
payload = _subscription_payload(_unique_suffix())
|
|
assert (await client.post("/api/push/subscribe", json=payload)).status_code == 204
|
|
|
|
updated = {**payload, "keys": {"p256dh": "new-p256dh", "auth": "new-auth"}}
|
|
assert (await client.post("/api/push/subscribe", json=updated)).status_code == 204
|
|
|
|
result = await db_session.execute(
|
|
select(PushSubscription).where(PushSubscription.endpoint == payload["endpoint"])
|
|
)
|
|
rows = result.scalars().all()
|
|
assert len(rows) == 1
|
|
assert rows[0].p256dh_key == "new-p256dh"
|
|
|
|
|
|
async def test_unsubscribe_removes_row(client, db_session):
|
|
await register_and_login(client, db_session, username="alice")
|
|
payload = _subscription_payload(_unique_suffix())
|
|
await client.post("/api/push/subscribe", json=payload)
|
|
|
|
resp = await client.request(
|
|
"DELETE", "/api/push/subscribe", json={"endpoint": payload["endpoint"]}
|
|
)
|
|
assert resp.status_code == 204
|
|
|
|
result = await db_session.execute(
|
|
select(PushSubscription).where(PushSubscription.endpoint == payload["endpoint"])
|
|
)
|
|
assert result.scalars().all() == []
|
|
|
|
|
|
def _register_ws(ws_client, username: str) -> dict:
|
|
from app.schemas.user import UserCreate
|
|
from app.services.auth_service import register_user
|
|
|
|
async def _seed():
|
|
async with ws_client.session_factory() as session:
|
|
await register_user(
|
|
session,
|
|
UserCreate(username=username, email=f"{username}@example.com", password="password123"),
|
|
)
|
|
|
|
ws_client.portal.call(_seed)
|
|
resp = ws_client.post(
|
|
"/api/auth/login", json={"username_or_email": username, "password": "password123"}
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
return resp.json()
|
|
|
|
|
|
def _unique(prefix: str) -> str:
|
|
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
|
|
|
|
|
def _recv(ws) -> dict:
|
|
"""Reads the next frame, transparently discarding member_updated
|
|
presence-change broadcasts -- another connection in the same room going
|
|
online/offline is real, expected noise these tests aren't about."""
|
|
while True:
|
|
msg = ws.receive_json()
|
|
if msg.get("type") != "member_updated":
|
|
return msg
|
|
|
|
|
|
def _fetch_subscriptions(ws_client, user_id: str) -> list[PushSubscription]:
|
|
async def _query():
|
|
async with ws_client.session_factory() as session:
|
|
result = await session.execute(
|
|
select(PushSubscription).where(PushSubscription.user_id == uuid.UUID(user_id))
|
|
)
|
|
return list(result.scalars().all())
|
|
|
|
return ws_client.portal.call(_query)
|
|
|
|
|
|
def test_ws_message_pushes_offline_member_only(ws_client, monkeypatch):
|
|
calls = []
|
|
|
|
def fake_webpush(**kwargs):
|
|
calls.append(kwargs)
|
|
|
|
monkeypatch.setattr("app.services.push_service.webpush", fake_webpush)
|
|
|
|
alice = _register_ws(ws_client, _unique("alice"))
|
|
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
|
|
|
bob = _register_ws(ws_client, _unique("bob"))
|
|
ws_client.post(f"/api/rooms/{room['id']}/join")
|
|
ws_client.post("/api/push/subscribe", json=_subscription_payload(_unique("bob")))
|
|
|
|
login_resp = ws_client.post(
|
|
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
|
|
)
|
|
assert login_resp.status_code == 200
|
|
|
|
with ws_client.websocket_connect("/ws/chat") as ws:
|
|
ws.send_json({"type": "join", "room_id": room["id"]})
|
|
assert ws.receive_json()["type"] == "joined"
|
|
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
|
assert ws.receive_json()["type"] == "message"
|
|
# The handler processes frames strictly sequentially, so a second
|
|
# (idempotent) join only gets acked once the "message" frame's full
|
|
# handling -- including the offline-push step -- has completed. A
|
|
# plain `with` block exit doesn't guarantee that: closing can race
|
|
# ahead of (and cancel) still-in-flight server-side work.
|
|
ws.send_json({"type": "join", "room_id": room["id"]})
|
|
assert ws.receive_json()["type"] == "joined"
|
|
|
|
assert len(calls) == 1
|
|
assert calls[0]["subscription_info"]["endpoint"].startswith("https://push.example.com/ep-bob")
|
|
assert "hello" in calls[0]["data"]
|
|
assert alice["username"] in calls[0]["data"] # sender attribution in the payload
|
|
|
|
|
|
def test_ws_message_no_push_when_member_connected(ws_client, monkeypatch):
|
|
calls = []
|
|
monkeypatch.setattr("app.services.push_service.webpush", lambda **kw: calls.append(kw))
|
|
|
|
alice = _register_ws(ws_client, _unique("alice"))
|
|
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
|
|
|
bob = _register_ws(ws_client, _unique("bob"))
|
|
ws_client.post(f"/api/rooms/{room['id']}/join")
|
|
ws_client.post("/api/push/subscribe", json=_subscription_payload(_unique("bob")))
|
|
|
|
with ws_client.websocket_connect("/ws/chat") as bob_ws:
|
|
bob_ws.send_json({"type": "join", "room_id": room["id"]})
|
|
assert bob_ws.receive_json()["type"] == "joined"
|
|
|
|
ws_client.post(
|
|
"/api/auth/login",
|
|
json={"username_or_email": alice["username"], "password": "password123"},
|
|
)
|
|
with ws_client.websocket_connect("/ws/chat") as alice_ws:
|
|
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
|
assert alice_ws.receive_json()["type"] == "joined"
|
|
alice_ws.send_json({"type": "message", "room_id": room["id"], "content": "hi"})
|
|
assert alice_ws.receive_json()["type"] == "message"
|
|
# bob is connected too -- he should get the broadcast, not a push
|
|
assert _recv(bob_ws)["type"] == "message"
|
|
|
|
assert calls == []
|
|
|
|
|
|
def test_expired_subscription_is_cleaned_up(ws_client, monkeypatch):
|
|
class FakeResponse:
|
|
status_code = 410
|
|
|
|
def fake_webpush(**kwargs):
|
|
raise WebPushException("gone", response=FakeResponse())
|
|
|
|
monkeypatch.setattr("app.services.push_service.webpush", fake_webpush)
|
|
|
|
alice = _register_ws(ws_client, _unique("alice"))
|
|
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
|
|
|
bob = _register_ws(ws_client, _unique("bob"))
|
|
ws_client.post(f"/api/rooms/{room['id']}/join")
|
|
ws_client.post("/api/push/subscribe", json=_subscription_payload(_unique("bob")))
|
|
assert len(_fetch_subscriptions(ws_client, bob["id"])) == 1
|
|
|
|
ws_client.post(
|
|
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
|
|
)
|
|
with ws_client.websocket_connect("/ws/chat") as ws:
|
|
ws.send_json({"type": "join", "room_id": room["id"]})
|
|
assert ws.receive_json()["type"] == "joined"
|
|
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
|
assert ws.receive_json()["type"] == "message"
|
|
# See test_ws_message_pushes_offline_member_only for why this sync
|
|
# barrier is needed before checking server-side push side effects.
|
|
ws.send_json({"type": "join", "room_id": room["id"]})
|
|
assert ws.receive_json()["type"] == "joined"
|
|
|
|
assert _fetch_subscriptions(ws_client, bob["id"]) == []
|
|
|
|
|
|
def test_non_gone_push_failure_logs_response_detail_and_keeps_subscription(ws_client, monkeypatch):
|
|
# #56: a real WNS 400 carries its actual reason in a response *header*,
|
|
# not the body -- str(WebPushException) alone (what used to be logged)
|
|
# would have shown neither, which is exactly why that bug took a DB
|
|
# dump + journalctl correlation to diagnose instead of one log line.
|
|
class FakeResponse:
|
|
status_code = 400
|
|
text = "Bad Request"
|
|
headers = {"X-WNS-Error-Description": "Ttl value conflicts with X-WNS-Cache-Policy"}
|
|
|
|
def fake_webpush(**kwargs):
|
|
raise WebPushException("Push failed: 400 Bad Request", response=FakeResponse())
|
|
|
|
monkeypatch.setattr("app.services.push_service.webpush", fake_webpush)
|
|
|
|
# caplog's handler capture isn't reliable here -- the actual push send
|
|
# (and its logger.warning call) runs on ws_client_factory's background
|
|
# portal thread (see that fixture's own docstring), not pytest's main
|
|
# thread. Patching the logger call directly sidesteps that instead of
|
|
# depending on cross-thread log propagation.
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
"app.services.push_service.logger.warning",
|
|
lambda msg, *args: calls.append(msg % args),
|
|
)
|
|
|
|
alice = _register_ws(ws_client, _unique("alice"))
|
|
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
|
|
|
bob = _register_ws(ws_client, _unique("bob"))
|
|
ws_client.post(f"/api/rooms/{room['id']}/join")
|
|
ws_client.post("/api/push/subscribe", json=_subscription_payload(_unique("bob")))
|
|
assert len(_fetch_subscriptions(ws_client, bob["id"])) == 1
|
|
|
|
ws_client.post(
|
|
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
|
|
)
|
|
with ws_client.websocket_connect("/ws/chat") as ws:
|
|
ws.send_json({"type": "join", "room_id": room["id"]})
|
|
assert ws.receive_json()["type"] == "joined"
|
|
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
|
assert ws.receive_json()["type"] == "message"
|
|
ws.send_json({"type": "join", "room_id": room["id"]})
|
|
assert ws.receive_json()["type"] == "joined"
|
|
|
|
# A 400 isn't "gone" (404/410) -- the subscription stays, unlike the
|
|
# expired-subscription case above.
|
|
assert len(_fetch_subscriptions(ws_client, bob["id"])) == 1
|
|
|
|
assert len(calls) == 1
|
|
assert "Bad Request" in calls[0]
|
|
assert "Ttl value conflicts with X-WNS-Cache-Policy" in calls[0]
|
|
|
|
|
|
def test_wns_endpoint_gets_cache_policy_header(ws_client, monkeypatch):
|
|
calls = []
|
|
monkeypatch.setattr("app.services.push_service.webpush", lambda **kw: calls.append(kw))
|
|
|
|
alice = _register_ws(ws_client, _unique("alice"))
|
|
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
|
|
|
bob = _register_ws(ws_client, _unique("bob"))
|
|
ws_client.post(f"/api/rooms/{room['id']}/join")
|
|
ws_client.post(
|
|
"/api/push/subscribe",
|
|
json={
|
|
"endpoint": f"https://wns2-by3p.notify.windows.com/w/{_unique('bob')}",
|
|
"keys": {"p256dh": "p256dh-bob", "auth": "auth-bob"},
|
|
},
|
|
)
|
|
|
|
ws_client.post(
|
|
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
|
|
)
|
|
with ws_client.websocket_connect("/ws/chat") as ws:
|
|
ws.send_json({"type": "join", "room_id": room["id"]})
|
|
assert ws.receive_json()["type"] == "joined"
|
|
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
|
assert ws.receive_json()["type"] == "message"
|
|
ws.send_json({"type": "join", "room_id": room["id"]})
|
|
assert ws.receive_json()["type"] == "joined"
|
|
|
|
assert len(calls) == 1
|
|
assert calls[0]["headers"] == {"X-WNS-Cache-Policy": "no-cache"}
|
|
|
|
|
|
def test_non_wns_endpoint_gets_no_extra_headers(ws_client, monkeypatch):
|
|
calls = []
|
|
monkeypatch.setattr("app.services.push_service.webpush", lambda **kw: calls.append(kw))
|
|
|
|
alice = _register_ws(ws_client, _unique("alice"))
|
|
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
|
|
|
bob = _register_ws(ws_client, _unique("bob"))
|
|
ws_client.post(f"/api/rooms/{room['id']}/join")
|
|
ws_client.post("/api/push/subscribe", json=_subscription_payload(_unique("bob")))
|
|
|
|
ws_client.post(
|
|
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
|
|
)
|
|
with ws_client.websocket_connect("/ws/chat") as ws:
|
|
ws.send_json({"type": "join", "room_id": room["id"]})
|
|
assert ws.receive_json()["type"] == "joined"
|
|
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
|
assert ws.receive_json()["type"] == "message"
|
|
ws.send_json({"type": "join", "room_id": room["id"]})
|
|
assert ws.receive_json()["type"] == "joined"
|
|
|
|
assert len(calls) == 1
|
|
assert calls[0]["headers"] is None
|