Cover the push providers with tests and document the subsystem
Unit tests for the provider registry, both providers' registration parsing, the APNs payload translation, provider token signing and caching, header and status mapping against a mock transport, provider grouping and the delivery timeout clamp, plus service tests for the configuration surface, the retention sweep and the per-provider metrics. Api tests cover the provider listing on GET /push.json, registration with and without an explicit provider, idempotency and the rejection of an unknown or unconfigured provider. Provider settings in unit tests are supplied by monkeypatching the provider's setting reader rather than writing site_settings, because the unit tier shares its database with the running api-tier server. devplacepy/push/CLAUDE.md documents the protocol, how to add a provider, the invariants and the APNs specifics; the root, routers and services files point at it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
53ddf4f233
commit
7674dac628
@ -148,6 +148,7 @@ Nested `CLAUDE.md` files (loaded automatically by Claude Code only when a file i
|
||||
| `devplacepy/services/bot/CLAUDE.md` | `BotsService` fleet |
|
||||
| `devplacepy/services/dbapi/CLAUDE.md` | `/dbapi` primary-admin-only read-only database API |
|
||||
| `devplacepy/services/pubsub/CLAUDE.md` | Database-free pub/sub bus |
|
||||
| `devplacepy/push/CLAUDE.md` | Push notification providers: the `PushProvider` protocol, the registry, Web Push and APNs, registration storage |
|
||||
| `devplacepy/services/game/CLAUDE.md` | Code Farm idle game (economy invariants, raids, the one-pure-function rule) |
|
||||
| `devplacepy/services/quiz/CLAUDE.md` | Quizzes (the terminal publish lock, attempt atomicity, answer-key withholding, AI free-text grading, the best-attempt scoreboard) |
|
||||
| `devplacepy/services/CLAUDE.md` | Background task queue, AI correction/modifier, presence, live view relay, `BaseService`/`ServiceManager` |
|
||||
|
||||
52
devplacepy/push/CLAUDE.md
Normal file
52
devplacepy/push/CLAUDE.md
Normal file
@ -0,0 +1,52 @@
|
||||
This file documents `devplacepy/push/` - push notification delivery and its provider architecture. Claude Code loads it automatically whenever a file under this directory is read or edited.
|
||||
|
||||
## What this package is
|
||||
|
||||
One delivery library behind one public surface. `devplacepy.push` exports `notify_user`, `register`, `ensure_certificates`, `public_key_standard_b64` and the Web Push crypto helpers; every caller in the codebase (`main.py`, `utils/notifications.py`, `routers/push.py`) imports only those names. Everything else is internal to the package.
|
||||
|
||||
| Module | Role |
|
||||
|---|---|
|
||||
| `providers/base.py` | `PushProvider` protocol, the `Delivery` outcome and the three outcome constants |
|
||||
| `providers/webpush.py` | VAPID key material, `aesgcm` payload encryption, the Web Push provider |
|
||||
| `providers/apns.py` | Apple Push Notification service provider (token based, HTTP/2) |
|
||||
| `providers/__init__.py` | `PROVIDERS` registry, `get`, `active`, `is_active`, `admin_fields`, `client_config` |
|
||||
| `store.py` | Every `push_registration` read and write |
|
||||
| `delivery.py` | `notify_user`: group by provider, one shared client, one prepared body per provider |
|
||||
|
||||
The admin configuration surface lives in `devplacepy/services/push/service.py` (`PushService`), not here.
|
||||
|
||||
## Adding a provider
|
||||
|
||||
1. Write `providers/<name>.py` with a `PushProvider` subclass: `name`, `label`, `config_fields`, `is_configured`, `parse_registration`, `prepare`, `deliver`, optionally `client_config`.
|
||||
2. Add one entry to `PROVIDERS` in `providers/__init__.py`.
|
||||
|
||||
That is the whole change. The registration route, the delivery loop, the admin page, the audit record, the metrics and the docs are written against the protocol and need no edit. The `Enabled` toggle (`push_<name>_enabled`) comes from the base class, so a provider never declares its own.
|
||||
|
||||
## Invariants
|
||||
|
||||
- **Zero cost for the request.** Delivery is reached only through `utils/notifications.py` `_schedule_push`, a fire-and-forget task. Never make a route await `notify_user`, and never add a queue or a table to this path.
|
||||
- **`deliver` never raises.** Return `Delivery(REJECTED, detail)` instead. `delivery.py` guards anyway, but a raising provider costs a log line per subscription.
|
||||
- **A provider that is not configured is inert, never an error.** `is_configured()` is false, `is_active()` is false, the delivery loop skips it, and `POST /push.json` refuses a registration for it with 400. Nothing else in the platform notices.
|
||||
- **`DEAD` is the only outcome that touches the database.** It soft-deletes the registration (`deleted_at`), exactly like a `404`/`410` Web Push endpoint always did. `REJECTED` keeps the row.
|
||||
- **Every insert writes `deleted_at: None`,** and every read filters `deleted_at IS NULL`. `push_registration` deliberately stays out of `SOFT_DELETE_TABLES` (no `deleted_by`, not restorable from Trash) - a dead device token has no owner action to undo.
|
||||
- **A row without a provider is a Web Push row.** `store.provider_of` resolves `None`/`""` to `DEFAULT_PROVIDER`, so a row written by an old worker during a deploy still delivers. `init_db` backfills the column once with a single converging `UPDATE`.
|
||||
|
||||
## Storage
|
||||
|
||||
`push_registration` columns are ensured in `init_db` (`database/schema.py`) because `dataset` only creates the columns of a table's first insert, and `find(provider=...)` against a missing column matches nothing.
|
||||
|
||||
| Column | webpush | apns |
|
||||
|---|---|---|
|
||||
| `provider` | `webpush` | `apns` |
|
||||
| `endpoint`, `key_auth`, `key_p256dh` | set | `NULL` |
|
||||
| `token` | `NULL` | device token |
|
||||
|
||||
Deduplication is generic: `store.register` looks up `user_uid` + `provider` + exactly the fields the provider's `parse_registration` returned, so a provider never writes its own identity rule.
|
||||
|
||||
## APNs specifics
|
||||
|
||||
- `POST https://{host}/3/device/{token}` over HTTP/2, host from `push_apns_environment` (`api.push.apple.com` or `api.sandbox.push.apple.com`; an unrecognised value falls back to production). HTTP/2 comes from `stealth_async_client` because the origin is `https` - the cleartext downgrade in `curl_transport` does not apply.
|
||||
- Provider token: `ES256`, header `kid` = key id, claims `iss` = team id and `iat`. Cached per credential fingerprint for 45 minutes, so a worker signs at most one token per 45 minutes; Apple refuses tokens regenerated faster than every 20 minutes. Editing any credential changes the fingerprint and takes effect on the next delivery, with no restart.
|
||||
- A `.p8` that does not parse is cached as a failure for the same window, so a misconfiguration costs one error log per window rather than one parse per notification.
|
||||
- `410`, or any status carrying reason `BadDeviceToken`, `Unregistered`, `ExpiredToken`, `DeviceTokenNotForTopic` or `TopicDisallowed`, is `DEAD`. Everything else is `REJECTED`.
|
||||
- The shared payload dict (`title`, `message`, `icon`, `url`) is translated once per batch into `aps.alert` plus the custom `url`/`icon` keys, mirroring what `service-worker.js` does for Web Push. `thread-id` mirrors the service worker's notification `tag`.
|
||||
@ -45,7 +45,7 @@ Prefixes are wired in `main.py`:
|
||||
| `/dbapi` | dbapi/ package - **primary-administrator-only, strictly READ-ONLY** generic database API over `dataset` (never inserts/updates/deletes/restores; the write surface was removed because it bypassed every per-route admin safeguard). `tables.py` (`GET /tables`, `GET /{table}/schema`), `crud.py` (read only: `GET /{table}` + `GET /{table}/{key}/{value}`, `?include_deleted`), `query.py` (`POST /query` hard SELECT-only validated read-only run; `POST /query/async` + `GET /query/{uid}` + `WS /query/{uid}/ws` via `DbApiJobService` kind `dbquery`), `nl.py` (`POST /nl` natural-language->SQL via the AI gateway, returns validated SELECT, `execute=true` runs it read-only). **Auth = the PRIMARY administrator only** (the earliest-created Admin, `utils.is_primary_admin` / `database.get_primary_admin_uid` - the same identity that gates backup downloads) by session/api_key (`services/dbapi/policy.py`); every other administrator is refused like a member, and there is **no internal-key path** - the gateway `internal_gateway_key()` is NOT accepted (no service-to-service access). SQL validated by `services/dbapi/validate.py` (sqlglot classify + suspicious-flag + read-only `EXPLAIN` dry-run). Devii tools `db_*` are read-only (list/get/query/nl; no write tools) and flagged `requires_primary_admin=True`, so they are added to the LLM tool list ONLY for the primary administrator (every other session never sees them and Devii is unaware they exist). `services/pubsub/policy.py` resolves its own admin/internal actor and does NOT reuse `dbapi.policy.caller_for`, so the primary-admin restriction does not leak onto the pub/sub bus. See `devplacepy/services/dbapi/CLAUDE.md` |
|
||||
| `/quizzes` | quizzes/ package - **Quizzes**: `index.py` (hub, create, import, detail, export, edit, delete, publish, per-quiz leaderboard, cross-quiz scoreboard), `questions.py` (builder page + question CRUD + reorder), `attempts.py` (start/resume, play, answer, finish, results). The hub `GET /quizzes` is the approved three-column `.feed-layout` reused verbatim from `/feed`: filters + search left (`?filter=all|todo|done|mine|drafts`, `?search=`, `?page=`), the quiz list centre with the **New quiz** and **Create quiz with Devii** actions, and the cross-quiz **scoreboard** rail right (score per user, best attempt per quiz, 15s display cache). Publishing is terminal - every write on a published quiz is a 400 and there is no unpublish route. See `devplacepy/services/quiz/CLAUDE.md` |
|
||||
| `/game` | game/ package - the **Code Farm** idle game (`index.py` + `farm.py`): `GET /game` (page), `GET /game/state`, `GET /game/leaderboard?board=`, `POST /game/{plant,harvest,buy-plot,upgrade,fertilize,daily,grant,perk,prestige,legacy,mastery,quests/claim}`, `POST /game/{defense/upgrade,defense/downgrade,infrastructure/buy,cosmetics/buy,cosmetics/equip}`, plus social `GET /game/farm/{username}` and `POST /game/farm/{username}/{water,steal}`. See `devplacepy/services/game/CLAUDE.md` |
|
||||
| (none) | push.py - web push + PWA: `GET`/`POST /push.json` (VAPID public key / subscription register), `GET /service-worker.js`, `GET /manifest.json` |
|
||||
| (none) | push.py - push + PWA: `GET /push.json` (VAPID public key + the providers accepting registrations), `POST /push.json` (register with any active provider; a body without `provider` is a `webpush` body, so existing clients are unchanged; an unknown, disabled or unconfigured provider is a 400), `GET /service-worker.js`, `GET /manifest.json`. Provider protocol and delivery live in `devplacepy/push/` - see `devplacepy/push/CLAUDE.md` |
|
||||
| (none) | docs.py (docs/ package) - the documentation site (prose pages + API reference). See `routers/docs/CLAUDE.md` for the deep detail on this tree (`DOCS_PAGES`, audience tiers, prose rendering pipeline) |
|
||||
| `/pubsub` | pubsub.py - **database-free** publish/subscribe bus. `WS /pubsub/ws` (subscribe/unsubscribe/publish frames, `foo.*` wildcards), `POST /pubsub/publish` + `GET /pubsub/topics` (admin/internal). Lock-owner-gated WS (`4013` retry) so subscribers converge on one worker; topic authz in `services/pubsub/policy.py` (`user.{uid}.*` private, `public.*` shared, admin/internal anywhere, guests opt-in). In-process `services.pubsub.publish(topic, data)` for backends; frontend `app.pubsub`. See `devplacepy/services/pubsub/CLAUDE.md` |
|
||||
| (none) | seo.py - `/robots.txt`, `/sitemap.xml` |
|
||||
|
||||
@ -51,7 +51,7 @@ Generic, lightweight, **fire-and-forget** offload for non-critical side-effects
|
||||
|
||||
`devplacepy/services/` provides a generic framework for running background async services alongside the FastAPI server. `BaseService` provides the async run loop, a `deque(maxlen=20)` log buffer, and graceful cancellation; `ServiceManager` is a singleton that registers, starts, and stops services. Services are fully managed from the **Services admin tab** (`/admin/services`): start/stop, enable-on-boot, run-now, edit parameters, clear logs, adjustable log buffer size, with live status. All of this is generic - a new service gets it for free by declaring its config and implementing `run_once`.
|
||||
|
||||
This section covers only the shared machinery. The individual services built on top of it live in their own subdirectories with their own nested CLAUDE.md: `NewsService` (`services/news/`, see `devplacepy/services/news/CLAUDE.md`), `GatewayService` and provider/model routing (`services/openai_gateway/`, see `devplacepy/services/openai_gateway/CLAUDE.md`), `DeviiService` (`services/devii/`, see `devplacepy/services/devii/CLAUDE.md`), and the bot fleet service (`services/bot/`, see `devplacepy/services/bot/CLAUDE.md`).
|
||||
This section covers only the shared machinery. The individual services built on top of it live in their own subdirectories with their own nested CLAUDE.md: `NewsService` (`services/news/`, see `devplacepy/services/news/CLAUDE.md`), `GatewayService` and provider/model routing (`services/openai_gateway/`, see `devplacepy/services/openai_gateway/CLAUDE.md`), `DeviiService` (`services/devii/`, see `devplacepy/services/devii/CLAUDE.md`), and the bot fleet service (`services/bot/`, see `devplacepy/services/bot/CLAUDE.md`). `PushService` (`services/push/`) is the thinnest example of the pattern: it owns no loop work beyond pruning dead subscriptions, and exists mainly so every push provider's configuration is edited through the same `ConfigField` surface as every other subsystem - its `config_fields` are assembled from `devplacepy.push.providers.admin_fields()`, so a new provider appears at `/admin/services/push` with no edit to the service. Push delivery does NOT depend on that service running (see `devplacepy/push/CLAUDE.md`).
|
||||
|
||||
### DB-backed state (correct across workers)
|
||||
|
||||
|
||||
@ -64,3 +64,56 @@ def test_register_invalid_json_rejected(app_server):
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_public_key_endpoint_lists_providers(app_server):
|
||||
r = requests.get(f"{BASE_URL}/push.json")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["publicKey"]
|
||||
assert body["providers"]["webpush"]["publicKey"] == body["publicKey"]
|
||||
|
||||
|
||||
def test_register_accepts_an_explicit_webpush_provider(app_server):
|
||||
s = _session_push()
|
||||
r = s.post(
|
||||
f"{BASE_URL}/push.json",
|
||||
json={
|
||||
"provider": "webpush",
|
||||
"endpoint": "https://push.example.com/explicit",
|
||||
"keys": {"p256dh": "p256dh_fake", "auth": "auth_fake"},
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json().get("registered") is True
|
||||
|
||||
|
||||
def test_register_is_idempotent_for_the_same_subscription(app_server):
|
||||
s = _session_push()
|
||||
body = {
|
||||
"endpoint": "https://push.example.com/idempotent",
|
||||
"keys": {"p256dh": "p256dh_fake", "auth": "auth_fake"},
|
||||
}
|
||||
assert s.post(f"{BASE_URL}/push.json", json=body).status_code == 200
|
||||
assert s.post(f"{BASE_URL}/push.json", json=body).status_code == 200
|
||||
|
||||
|
||||
def test_register_unknown_provider_rejected(app_server):
|
||||
s = _session_push()
|
||||
r = s.post(
|
||||
f"{BASE_URL}/push.json",
|
||||
json={"provider": "carrier-pigeon", "token": "a" * 64},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_register_apns_rejected_while_unconfigured(app_server):
|
||||
s = _session_push()
|
||||
r = s.post(f"{BASE_URL}/push.json", json={"provider": "apns", "token": "a" * 64})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_register_non_object_body_rejected(app_server):
|
||||
s = _session_push()
|
||||
r = s.post(f"{BASE_URL}/push.json", json=["nope"])
|
||||
assert r.status_code == 400
|
||||
|
||||
@ -48,3 +48,298 @@ def test_create_notification_authorization_is_jwt():
|
||||
|
||||
token = push.create_notification_authorization("https://push.example.com/endpoint")
|
||||
assert token.count(".") == 2
|
||||
|
||||
|
||||
def test_provider_registry_resolves_default_for_missing_name():
|
||||
from devplacepy.push import providers
|
||||
|
||||
assert providers.get(None) is providers.PROVIDERS["webpush"]
|
||||
assert providers.get("") is providers.PROVIDERS["webpush"]
|
||||
assert providers.get(" APNS ") is providers.PROVIDERS["apns"]
|
||||
assert providers.get("nope") is None
|
||||
|
||||
|
||||
def test_webpush_parse_registration_accepts_subscription_shape():
|
||||
from devplacepy.push import providers
|
||||
|
||||
webpush = providers.PROVIDERS["webpush"]
|
||||
fields = webpush.parse_registration(
|
||||
{
|
||||
"endpoint": "https://push.example.com/sub",
|
||||
"expirationTime": None,
|
||||
"keys": {"p256dh": "p", "auth": "a"},
|
||||
}
|
||||
)
|
||||
assert fields == {
|
||||
"endpoint": "https://push.example.com/sub",
|
||||
"key_auth": "a",
|
||||
"key_p256dh": "p",
|
||||
}
|
||||
|
||||
|
||||
def test_webpush_parse_registration_rejects_incomplete_bodies():
|
||||
from devplacepy.push import providers
|
||||
|
||||
webpush = providers.PROVIDERS["webpush"]
|
||||
assert webpush.parse_registration({"endpoint": "https://push.example.com/x"}) is None
|
||||
assert webpush.parse_registration({"keys": {"p256dh": "p", "auth": "a"}}) is None
|
||||
assert (
|
||||
webpush.parse_registration(
|
||||
{"endpoint": "https://push.example.com/x", "keys": {"p256dh": "p"}}
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_apns_parse_registration_validates_device_token():
|
||||
from devplacepy.push import providers
|
||||
|
||||
apns = providers.PROVIDERS["apns"]
|
||||
token = "a1b2c3d4" * 8
|
||||
assert apns.parse_registration({"token": f" {token} "}) == {"token": token}
|
||||
assert apns.parse_registration({"token": "abc"}) is None
|
||||
assert apns.parse_registration({"token": "z" * 64}) is None
|
||||
assert apns.parse_registration({"token": "a" * 500}) is None
|
||||
assert apns.parse_registration({"token": None}) is None
|
||||
assert apns.parse_registration({}) is None
|
||||
|
||||
|
||||
def test_apns_prepare_translates_the_shared_payload():
|
||||
import json
|
||||
from devplacepy.push import providers
|
||||
|
||||
body = json.loads(
|
||||
providers.PROVIDERS["apns"].prepare(
|
||||
{
|
||||
"title": "DevPlace",
|
||||
"message": "You have a new notification.",
|
||||
"icon": "/static/apple-touch-icon.png",
|
||||
"url": "/notifications",
|
||||
}
|
||||
)
|
||||
)
|
||||
assert body["aps"]["alert"] == {
|
||||
"title": "DevPlace",
|
||||
"body": "You have a new notification.",
|
||||
}
|
||||
assert body["aps"]["thread-id"] == "devplace-notification"
|
||||
assert body["url"] == "/notifications"
|
||||
assert body["icon"] == "/static/apple-touch-icon.png"
|
||||
|
||||
|
||||
def test_apns_prepare_survives_an_empty_payload():
|
||||
import json
|
||||
from devplacepy.push import providers
|
||||
|
||||
body = json.loads(providers.PROVIDERS["apns"].prepare({}))
|
||||
assert body["aps"]["alert"]["title"] == "DevPlace"
|
||||
assert body["url"] == "/notifications"
|
||||
|
||||
|
||||
def _apns_settings(monkeypatch, **values):
|
||||
from devplacepy.push.providers import apns
|
||||
|
||||
defaults = {
|
||||
apns.TEAM_ID_KEY: "",
|
||||
apns.KEY_ID_KEY: "",
|
||||
apns.AUTH_KEY_KEY: "",
|
||||
apns.TOPIC_KEY: "",
|
||||
apns.ENVIRONMENT_KEY: "",
|
||||
}
|
||||
defaults.update(values)
|
||||
monkeypatch.setattr(apns, "_setting", lambda key: defaults.get(key, ""))
|
||||
apns._token_state.clear()
|
||||
return defaults
|
||||
|
||||
|
||||
def _ec_private_key_pem():
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
|
||||
key = ec.generate_private_key(ec.SECP256R1())
|
||||
return key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
).decode("utf-8")
|
||||
|
||||
|
||||
def test_apns_is_configured_requires_every_credential(monkeypatch):
|
||||
from devplacepy.push import providers
|
||||
from devplacepy.push.providers import apns
|
||||
|
||||
provider = providers.PROVIDERS["apns"]
|
||||
_apns_settings(monkeypatch)
|
||||
assert provider.is_configured() is False
|
||||
assert providers.is_active(provider) is False
|
||||
|
||||
_apns_settings(
|
||||
monkeypatch,
|
||||
**{
|
||||
apns.TEAM_ID_KEY: "TEAMID1234",
|
||||
apns.KEY_ID_KEY: "KEYID12345",
|
||||
apns.AUTH_KEY_KEY: "pem",
|
||||
},
|
||||
)
|
||||
assert provider.is_configured() is False
|
||||
|
||||
_apns_settings(
|
||||
monkeypatch,
|
||||
**{
|
||||
apns.TEAM_ID_KEY: "TEAMID1234",
|
||||
apns.KEY_ID_KEY: "KEYID12345",
|
||||
apns.AUTH_KEY_KEY: "pem",
|
||||
apns.TOPIC_KEY: "nl.molodetz.devplace",
|
||||
},
|
||||
)
|
||||
assert provider.is_configured() is True
|
||||
|
||||
|
||||
def test_apns_host_falls_back_to_production(monkeypatch):
|
||||
from devplacepy.push.providers import apns
|
||||
|
||||
_apns_settings(monkeypatch)
|
||||
assert apns.host() == "api.push.apple.com"
|
||||
|
||||
_apns_settings(monkeypatch, **{apns.ENVIRONMENT_KEY: "sandbox"})
|
||||
assert apns.host() == "api.sandbox.push.apple.com"
|
||||
|
||||
_apns_settings(monkeypatch, **{apns.ENVIRONMENT_KEY: "nonsense"})
|
||||
assert apns.host() == "api.push.apple.com"
|
||||
|
||||
|
||||
def test_apns_provider_token_is_signed_and_cached(monkeypatch):
|
||||
import jwt
|
||||
from devplacepy.push.providers import apns
|
||||
|
||||
_apns_settings(monkeypatch)
|
||||
pem = _ec_private_key_pem()
|
||||
token = apns.provider_token("TEAMID1234", "KEYID12345", pem)
|
||||
assert apns.provider_token("TEAMID1234", "KEYID12345", pem) == token
|
||||
|
||||
header = jwt.get_unverified_header(token)
|
||||
claims = jwt.decode(token, options={"verify_signature": False})
|
||||
assert header["alg"] == "ES256"
|
||||
assert header["kid"] == "KEYID12345"
|
||||
assert claims["iss"] == "TEAMID1234"
|
||||
assert isinstance(claims["iat"], int)
|
||||
|
||||
other = apns.provider_token("TEAMID1234", "KEYID12345", _ec_private_key_pem())
|
||||
assert other != token
|
||||
|
||||
|
||||
def test_apns_provider_token_rejects_a_broken_auth_key(monkeypatch):
|
||||
import pytest
|
||||
from devplacepy.push.providers import apns
|
||||
|
||||
_apns_settings(monkeypatch)
|
||||
with pytest.raises(ValueError):
|
||||
apns.provider_token("TEAMID1234", "KEYID12345", "not-a-pem")
|
||||
with pytest.raises(ValueError):
|
||||
apns.provider_token("TEAMID1234", "KEYID12345", "not-a-pem")
|
||||
|
||||
|
||||
def _apns_response_status(monkeypatch, status, body):
|
||||
import httpx
|
||||
from tests.conftest import run_async
|
||||
from devplacepy.push import providers
|
||||
from devplacepy.push.providers import apns
|
||||
|
||||
_apns_settings(
|
||||
monkeypatch,
|
||||
**{
|
||||
apns.TEAM_ID_KEY: "TEAMID1234",
|
||||
apns.KEY_ID_KEY: "KEYID12345",
|
||||
apns.AUTH_KEY_KEY: _ec_private_key_pem(),
|
||||
apns.TOPIC_KEY: "nl.molodetz.devplace",
|
||||
},
|
||||
)
|
||||
provider = providers.PROVIDERS["apns"]
|
||||
seen = {}
|
||||
|
||||
def handler(request):
|
||||
seen["url"] = str(request.url)
|
||||
seen["headers"] = dict(request.headers)
|
||||
return httpx.Response(status, json=body)
|
||||
|
||||
async def run():
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(transport=transport) as client:
|
||||
return await provider.deliver(
|
||||
client, {"token": "a" * 64}, provider.prepare({"message": "hi"})
|
||||
)
|
||||
|
||||
return run_async(run()), seen
|
||||
|
||||
|
||||
def test_apns_delivery_maps_statuses(monkeypatch):
|
||||
from devplacepy.push import providers
|
||||
|
||||
accepted, seen = _apns_response_status(monkeypatch, 200, {})
|
||||
assert accepted.status == providers.ACCEPTED
|
||||
assert seen["url"] == f"https://api.push.apple.com/3/device/{'a' * 64}"
|
||||
assert seen["headers"]["apns-topic"] == "nl.molodetz.devplace"
|
||||
assert seen["headers"]["apns-push-type"] == "alert"
|
||||
assert seen["headers"]["apns-priority"] == "10"
|
||||
assert seen["headers"]["authorization"].startswith("bearer ")
|
||||
assert int(seen["headers"]["apns-expiration"]) > 0
|
||||
assert seen["headers"]["apns-id"]
|
||||
|
||||
gone, _ = _apns_response_status(monkeypatch, 410, {"reason": "Unregistered"})
|
||||
assert gone.status == providers.DEAD
|
||||
|
||||
bad_token, _ = _apns_response_status(monkeypatch, 400, {"reason": "BadDeviceToken"})
|
||||
assert bad_token.status == providers.DEAD
|
||||
|
||||
payload_error, _ = _apns_response_status(
|
||||
monkeypatch, 400, {"reason": "PayloadTooLarge"}
|
||||
)
|
||||
assert payload_error.status == providers.REJECTED
|
||||
|
||||
throttled, _ = _apns_response_status(monkeypatch, 429, {"reason": "TooManyRequests"})
|
||||
assert throttled.status == providers.REJECTED
|
||||
|
||||
|
||||
def test_apns_delivery_without_configuration_never_raises(monkeypatch):
|
||||
import httpx
|
||||
from tests.conftest import run_async
|
||||
from devplacepy.push import providers
|
||||
|
||||
_apns_settings(monkeypatch)
|
||||
provider = providers.PROVIDERS["apns"]
|
||||
|
||||
async def run():
|
||||
transport = httpx.MockTransport(lambda request: httpx.Response(200, json={}))
|
||||
async with httpx.AsyncClient(transport=transport) as client:
|
||||
return await provider.deliver(client, {"token": "a" * 64}, "{}")
|
||||
|
||||
assert run_async(run()).status == providers.REJECTED
|
||||
|
||||
|
||||
def test_group_by_provider_treats_a_missing_provider_as_webpush():
|
||||
from devplacepy.push.delivery import group_by_provider
|
||||
|
||||
grouped = group_by_provider(
|
||||
[
|
||||
{"id": 1, "provider": None},
|
||||
{"id": 2, "provider": ""},
|
||||
{"id": 3, "provider": "webpush"},
|
||||
{"id": 4, "provider": "apns"},
|
||||
]
|
||||
)
|
||||
assert sorted(grouped) == ["apns", "webpush"]
|
||||
assert len(grouped["webpush"]) == 3
|
||||
assert len(grouped["apns"]) == 1
|
||||
|
||||
|
||||
def test_delivery_timeout_is_clamped(monkeypatch):
|
||||
from devplacepy.push import delivery
|
||||
|
||||
monkeypatch.setattr(delivery, "get_int_setting", lambda key, default: default)
|
||||
assert delivery.timeout_seconds() == float(delivery.DEFAULT_TIMEOUT_SECONDS)
|
||||
|
||||
monkeypatch.setattr(delivery, "get_int_setting", lambda key, default: 0)
|
||||
assert delivery.timeout_seconds() == float(delivery.MIN_TIMEOUT_SECONDS)
|
||||
|
||||
monkeypatch.setattr(delivery, "get_int_setting", lambda key, default: 100000)
|
||||
assert delivery.timeout_seconds() == float(delivery.MAX_TIMEOUT_SECONDS)
|
||||
|
||||
117
tests/unit/services/push.py
Normal file
117
tests/unit/services/push.py
Normal file
@ -0,0 +1,117 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.push import store
|
||||
from devplacepy.services.push import PushService
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
|
||||
def _registration(user_uid, deleted_at=None, provider="webpush"):
|
||||
record = {
|
||||
"uid": generate_uid(),
|
||||
"user_uid": user_uid,
|
||||
"provider": provider,
|
||||
"endpoint": f"https://push.example.com/{generate_uid()}",
|
||||
"key_auth": "a",
|
||||
"key_p256dh": "p",
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"deleted_at": deleted_at,
|
||||
}
|
||||
get_table("push_registration").insert(record)
|
||||
return record
|
||||
|
||||
|
||||
def test_config_fields_cover_every_provider(local_db):
|
||||
keys = [field.key for field in PushService().all_fields()]
|
||||
for expected in (
|
||||
"service_push_enabled",
|
||||
"push_dead_retention_days",
|
||||
"push_delivery_timeout_seconds",
|
||||
"push_webpush_enabled",
|
||||
"push_webpush_subject",
|
||||
"push_apns_enabled",
|
||||
"push_apns_team_id",
|
||||
"push_apns_key_id",
|
||||
"push_apns_auth_key",
|
||||
"push_apns_topic",
|
||||
"push_apns_environment",
|
||||
):
|
||||
assert expected in keys
|
||||
|
||||
|
||||
def test_apns_auth_key_field_is_a_masked_secret(local_db):
|
||||
fields = {field.key: field for field in PushService().all_fields()}
|
||||
auth_key = fields["push_apns_auth_key"]
|
||||
assert auth_key.secret is True
|
||||
assert auth_key.type == "text"
|
||||
assert auth_key.display_value() == ""
|
||||
|
||||
|
||||
def test_run_once_prunes_only_stale_dead_rows(local_db, monkeypatch):
|
||||
from devplacepy.services import push as push_service
|
||||
|
||||
user_uid = f"prune_{generate_uid()}"
|
||||
live = _registration(user_uid)
|
||||
fresh_dead = _registration(
|
||||
user_uid,
|
||||
deleted_at=(datetime.now(timezone.utc) - timedelta(days=1)).isoformat(),
|
||||
)
|
||||
stale_dead = _registration(
|
||||
user_uid,
|
||||
deleted_at=(datetime.now(timezone.utc) - timedelta(days=90)).isoformat(),
|
||||
)
|
||||
|
||||
service = PushService()
|
||||
monkeypatch.setattr(
|
||||
push_service.service, "get_int_setting", lambda key, default: 30
|
||||
)
|
||||
from tests.conftest import run_async
|
||||
|
||||
run_async(service.run_once())
|
||||
|
||||
registrations = get_table("push_registration")
|
||||
assert registrations.find_one(uid=live["uid"]) is not None
|
||||
assert registrations.find_one(uid=fresh_dead["uid"]) is not None
|
||||
assert registrations.find_one(uid=stale_dead["uid"]) is None
|
||||
|
||||
|
||||
def test_run_once_with_retention_disabled_prunes_nothing(local_db, monkeypatch):
|
||||
from devplacepy.services import push as push_service
|
||||
from tests.conftest import run_async
|
||||
|
||||
user_uid = f"keep_{generate_uid()}"
|
||||
stale_dead = _registration(
|
||||
user_uid,
|
||||
deleted_at=(datetime.now(timezone.utc) - timedelta(days=900)).isoformat(),
|
||||
)
|
||||
|
||||
service = PushService()
|
||||
monkeypatch.setattr(push_service.service, "get_int_setting", lambda key, default: 0)
|
||||
run_async(service.run_once())
|
||||
|
||||
assert get_table("push_registration").find_one(uid=stale_dead["uid"]) is not None
|
||||
|
||||
|
||||
def test_metrics_count_live_rows_per_provider(local_db):
|
||||
user_uid = f"metrics_{generate_uid()}"
|
||||
_registration(user_uid)
|
||||
_registration(user_uid, provider="apns")
|
||||
|
||||
metrics = PushService().collect_metrics()
|
||||
assert metrics["webpush_active"] >= 1
|
||||
assert metrics["apns_active"] >= 1
|
||||
assert metrics["webpush_ready"] == 1
|
||||
assert "dead" in metrics
|
||||
|
||||
|
||||
def test_store_counts_treats_a_missing_provider_as_webpush(local_db):
|
||||
user_uid = f"legacy_{generate_uid()}"
|
||||
record = _registration(user_uid)
|
||||
get_table("push_registration").update(
|
||||
{"id": get_table("push_registration").find_one(uid=record["uid"])["id"], "provider": None},
|
||||
["id"],
|
||||
)
|
||||
|
||||
assert store.counts().get("webpush", 0) >= 1
|
||||
Loading…
Reference in New Issue
Block a user