A failed web push (e.g. VAPID key mismatch -> 401/403) was swallowed: /push/test still returned 202 and the UI showed 'Sent', while the only trace was a journald WARNING. Now: push.send logs failures to the errors JSONL (phase=push) so they show up like other errors; /push/test returns a 'failed' count; and the 'Send test' button reports 'No device' / 'Sent' / 'Failed' from the real result. Document the VAPID env vars (missing from thermograph.env.example) and how to pin/diagnose them.
166 lines
6.6 KiB
Python
166 lines
6.6 KiB
Python
"""Route-level tests for the accounts feature: auth (fastapi-users), subscription
|
|
CRUD, and the notifications endpoints. Uses a throwaway accounts DB (conftest points
|
|
THERMOGRAPH_ACCOUNTS_DB at a temp file) so nothing is written into the repo."""
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
import app as appmod
|
|
import db
|
|
|
|
V2 = "/thermograph/api/v2"
|
|
PW = "supersecret123"
|
|
|
|
|
|
@pytest.fixture(scope="module", autouse=True)
|
|
def _tables():
|
|
# TestClient(app) (no context manager) skips the lifespan, so create the
|
|
# account tables directly against the temp DB.
|
|
db.Base.metadata.create_all(db.sync_engine)
|
|
|
|
|
|
def _client():
|
|
return TestClient(appmod.app)
|
|
|
|
|
|
def _login(client, email):
|
|
r = client.post(f"{V2}/auth/register", json={"email": email, "password": PW})
|
|
assert r.status_code in (201, 400) # 400 only if the email already exists
|
|
r = client.post(f"{V2}/auth/login", data={"username": email, "password": PW})
|
|
assert r.status_code == 204 # sets the session cookie on the client
|
|
|
|
|
|
def test_auth_flow():
|
|
c = _client()
|
|
_login(c, "auth-flow@example.com")
|
|
me = c.get(f"{V2}/users/me")
|
|
assert me.status_code == 200 and me.json()["email"] == "auth-flow@example.com"
|
|
assert c.post(f"{V2}/auth/logout").status_code == 204
|
|
assert c.get(f"{V2}/users/me").status_code == 401
|
|
|
|
|
|
def test_requires_auth():
|
|
c = _client()
|
|
assert c.get(f"{V2}/subscriptions").status_code == 401
|
|
assert c.get(f"{V2}/notifications").status_code == 401
|
|
|
|
|
|
def test_subscription_crud():
|
|
c = _client()
|
|
_login(c, "crud@example.com")
|
|
r = c.post(f"{V2}/subscriptions", json={
|
|
"lat": 47.6, "lon": -122.3, "label": "Seattle", "threshold": 96,
|
|
"metrics": ["tmax", "feels"], "kind": "observed", "two_sided": True,
|
|
})
|
|
assert r.status_code == 201
|
|
sub = r.json()
|
|
assert sub["cell_id"] and sub["threshold"] == 96 and sub["metrics"] == ["tmax", "feels"]
|
|
|
|
assert len(c.get(f"{V2}/subscriptions").json()) == 1
|
|
|
|
patched = c.patch(f"{V2}/subscriptions/{sub['id']}", json={"threshold": 99, "active": False})
|
|
assert patched.status_code == 200
|
|
assert patched.json()["threshold"] == 99 and patched.json()["active"] is False
|
|
|
|
assert c.delete(f"{V2}/subscriptions/{sub['id']}").status_code == 204
|
|
assert c.get(f"{V2}/subscriptions").json() == []
|
|
|
|
|
|
def test_duplicate_and_validation():
|
|
c = _client()
|
|
_login(c, "dup@example.com")
|
|
body = {"lat": 33.4, "lon": -112.0, "threshold": 97, "metrics": ["tmax"], "kind": "observed"}
|
|
assert c.post(f"{V2}/subscriptions", json=body).status_code == 201
|
|
assert c.post(f"{V2}/subscriptions", json=body).status_code == 409 # same cell + kind
|
|
# a forecast subscription on the same cell is allowed (different kind)
|
|
assert c.post(f"{V2}/subscriptions", json={**body, "kind": "forecast"}).status_code == 201
|
|
# threshold must be 95..99
|
|
assert c.post(f"{V2}/subscriptions", json={**body, "threshold": 90}).status_code == 422
|
|
# at least one metric required
|
|
assert c.post(f"{V2}/subscriptions", json={**body, "metrics": []}).status_code == 422
|
|
|
|
|
|
def test_ownership_is_404():
|
|
a, b = _client(), _client()
|
|
_login(a, "owner-a@example.com")
|
|
_login(b, "owner-b@example.com")
|
|
sub_id = a.post(f"{V2}/subscriptions", json={
|
|
"lat": 40.7, "lon": -74.0, "threshold": 95, "metrics": ["tmax"], "kind": "observed",
|
|
}).json()["id"]
|
|
# B cannot see, patch, or delete A's subscription — 404, not 403 (no existence leak)
|
|
assert b.patch(f"{V2}/subscriptions/{sub_id}", json={"threshold": 98}).status_code == 404
|
|
assert b.delete(f"{V2}/subscriptions/{sub_id}").status_code == 404
|
|
assert b.get(f"{V2}/subscriptions").json() == []
|
|
|
|
|
|
def test_notifications_empty_and_read_all():
|
|
c = _client()
|
|
_login(c, "notif@example.com")
|
|
r = c.get(f"{V2}/notifications")
|
|
assert r.status_code == 200
|
|
assert r.json() == {"notifications": [], "unread_count": 0}
|
|
assert c.post(f"{V2}/notifications/read-all").status_code == 204
|
|
|
|
|
|
# --- web push ---------------------------------------------------------------
|
|
_SUB = {"endpoint": "https://push.example.com/ep-1", "keys": {"p256dh": "BKEY", "auth": "YXV0aA"}}
|
|
|
|
|
|
def test_push_vapid_key_is_open():
|
|
# The public key is not a secret and the browser needs it before login, so
|
|
# this route is unauthenticated.
|
|
r = _client().get(f"{V2}/push/vapid-key")
|
|
assert r.status_code == 200
|
|
assert isinstance(r.json()["key"], str) and r.json()["key"]
|
|
|
|
|
|
def test_push_requires_auth():
|
|
c = _client()
|
|
assert c.post(f"{V2}/push/subscribe", json=_SUB).status_code == 401
|
|
assert c.post(f"{V2}/push/test").status_code == 401
|
|
|
|
|
|
def test_push_subscribe_upsert_test_unsubscribe(monkeypatch):
|
|
import push
|
|
sent = []
|
|
monkeypatch.setattr(push, "send", lambda info, payload: sent.append(info["endpoint"]) or "ok")
|
|
c = _client()
|
|
_login(c, "push@example.com")
|
|
|
|
assert c.post(f"{V2}/push/subscribe", json=_SUB).status_code == 201
|
|
# Re-subscribe from the same device (same endpoint, new keys) upserts — one device.
|
|
assert c.post(f"{V2}/push/subscribe",
|
|
json={**_SUB, "keys": {"p256dh": "BNEW", "auth": "bmV3"}}).status_code == 201
|
|
|
|
r = c.post(f"{V2}/push/test")
|
|
assert r.status_code == 202
|
|
assert r.json() == {"devices": 1, "sent": 1, "pruned": 0, "failed": 0}
|
|
assert sent == [_SUB["endpoint"]]
|
|
|
|
assert c.request("DELETE", f"{V2}/push/subscribe",
|
|
json={"endpoint": _SUB["endpoint"]}).status_code == 204
|
|
assert c.post(f"{V2}/push/test").json()["devices"] == 0
|
|
|
|
|
|
def test_push_test_prunes_gone_endpoint(monkeypatch):
|
|
import push
|
|
monkeypatch.setattr(push, "send", lambda info, payload: "gone")
|
|
c = _client()
|
|
_login(c, "push-gone@example.com")
|
|
assert c.post(f"{V2}/push/subscribe", json=_SUB).status_code == 201
|
|
r = c.post(f"{V2}/push/test")
|
|
assert r.json() == {"devices": 1, "sent": 0, "pruned": 1, "failed": 0}
|
|
# the gone endpoint was removed, so a second pass sees no devices
|
|
assert c.post(f"{V2}/push/test").json()["devices"] == 0
|
|
|
|
|
|
def test_push_test_reports_failed_delivery(monkeypatch):
|
|
# A rejected delivery (e.g. VAPID mismatch) must be reported, not silently dropped:
|
|
# the endpoint keeps returning 202 but with sent:0 / failed:N so the client can tell.
|
|
import push
|
|
monkeypatch.setattr(push, "send", lambda info, payload: "error")
|
|
c = _client()
|
|
_login(c, "push-fail@example.com")
|
|
assert c.post(f"{V2}/push/subscribe", json=_SUB).status_code == 201
|
|
r = c.post(f"{V2}/push/test")
|
|
assert r.status_code == 202
|
|
assert r.json() == {"devices": 1, "sent": 0, "pruned": 0, "failed": 1}
|