forked from retoor/devplacepy
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user