2026-06-13 14:32:33 +00:00
|
|
|
# retoor <retoor@molodetz.nl>
|
|
|
|
|
|
|
|
|
|
import uuid
|
|
|
|
|
import requests
|
|
|
|
|
from tests.conftest import BASE_URL
|
|
|
|
|
def _session_push():
|
|
|
|
|
s = requests.Session()
|
|
|
|
|
name = f"push_{uuid.uuid4().hex[:10]}"
|
|
|
|
|
s.post(
|
|
|
|
|
f"{BASE_URL}/auth/signup",
|
|
|
|
|
data={
|
|
|
|
|
"username": name,
|
|
|
|
|
"email": f"{name}@test.dev",
|
|
|
|
|
"password": "secret123",
|
|
|
|
|
"confirm_password": "secret123",
|
2026-08-09 00:18:20 +02:00
|
|
|
"birth_date": "1990-01-01",
|
|
|
|
|
"accept_terms": "1",
|
2026-06-13 14:32:33 +00:00
|
|
|
},
|
|
|
|
|
allow_redirects=True,
|
|
|
|
|
)
|
|
|
|
|
return s
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_browser_base64_is_url_safe_unpadded():
|
|
|
|
|
import base64
|
|
|
|
|
from devplacepy import push
|
|
|
|
|
|
|
|
|
|
encoded = push.browser_base64(b"\xff\xfe\x00 hello")
|
|
|
|
|
assert "=" not in encoded
|
|
|
|
|
assert "+" not in encoded and "/" not in encoded
|
|
|
|
|
assert base64.urlsafe_b64decode(encoded + "==") == b"\xff\xfe\x00 hello"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_hkdf_returns_requested_length():
|
|
|
|
|
from devplacepy import push
|
|
|
|
|
|
|
|
|
|
derived = push.hkdf(b"input-key-material", b"salt", b"info", 16)
|
|
|
|
|
assert isinstance(derived, bytes)
|
|
|
|
|
assert len(derived) == 16
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_public_key_standard_b64_non_empty():
|
|
|
|
|
from devplacepy import push
|
|
|
|
|
|
|
|
|
|
assert push.public_key_standard_b64()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_create_notification_authorization_is_jwt():
|
|
|
|
|
from devplacepy import push
|
|
|
|
|
|
|
|
|
|
token = push.create_notification_authorization("https://push.example.com/endpoint")
|
|
|
|
|
assert token.count(".") == 2
|
2026-07-31 22:47:20 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-08-29 23:28:49 +02:00
|
|
|
def test_apns_dead_reasons_excludes_topic_misconfiguration(monkeypatch):
|
|
|
|
|
from devplacepy.push import providers
|
|
|
|
|
|
|
|
|
|
not_for_topic, _ = _apns_response_status(
|
|
|
|
|
monkeypatch, 400, {"reason": "DeviceTokenNotForTopic"}
|
|
|
|
|
)
|
|
|
|
|
assert not_for_topic.status == providers.REJECTED
|
|
|
|
|
|
|
|
|
|
disallowed, _ = _apns_response_status(monkeypatch, 400, {"reason": "TopicDisallowed"})
|
|
|
|
|
assert disallowed.status == providers.REJECTED
|
|
|
|
|
|
|
|
|
|
expired, _ = _apns_response_status(monkeypatch, 410, {"reason": "ExpiredToken"})
|
|
|
|
|
assert expired.status == providers.DEAD
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_apns_dead_carries_apns_confirmation_timestamp(monkeypatch):
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
from devplacepy.push import providers
|
|
|
|
|
|
|
|
|
|
gone, _ = _apns_response_status(
|
|
|
|
|
monkeypatch, 410, {"reason": "Unregistered", "timestamp": 1700000000000}
|
|
|
|
|
)
|
|
|
|
|
assert gone.status == providers.DEAD
|
|
|
|
|
assert gone.dead_before == datetime.fromtimestamp(
|
|
|
|
|
1700000000000 / 1000, tz=timezone.utc
|
|
|
|
|
).isoformat()
|
|
|
|
|
|
|
|
|
|
bad_token, _ = _apns_response_status(monkeypatch, 400, {"reason": "BadDeviceToken"})
|
|
|
|
|
assert bad_token.status == providers.DEAD
|
|
|
|
|
assert bad_token.dead_before is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_apns_auth_failure_invalidates_cached_token(monkeypatch, local_db):
|
|
|
|
|
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"]
|
|
|
|
|
pem = _ec_private_key_pem()
|
|
|
|
|
first = apns.provider_token("TEAMID1234", "KEYID12345", pem)
|
|
|
|
|
assert apns._token_state["current"]["token"] == first
|
|
|
|
|
assert apns._read_shared_token()["token"] == first
|
|
|
|
|
|
|
|
|
|
def handler(request):
|
|
|
|
|
return httpx.Response(403, json={"reason": "InvalidProviderToken"})
|
|
|
|
|
|
|
|
|
|
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"})
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
outcome = run_async(run())
|
|
|
|
|
assert outcome.status == providers.REJECTED
|
|
|
|
|
assert "current" not in apns._token_state
|
|
|
|
|
assert apns._read_shared_token() is None
|
|
|
|
|
|
|
|
|
|
second = apns.provider_token("TEAMID1234", "KEYID12345", pem)
|
|
|
|
|
assert second != first
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_apns_provider_token_is_shared_across_workers(monkeypatch, local_db):
|
|
|
|
|
from devplacepy.push.providers import apns
|
|
|
|
|
|
|
|
|
|
_apns_settings(monkeypatch)
|
|
|
|
|
pem = _ec_private_key_pem()
|
|
|
|
|
token = apns.provider_token("TEAMID1234", "KEYID12345", pem)
|
|
|
|
|
|
|
|
|
|
apns._token_state.clear()
|
|
|
|
|
reused = apns.provider_token("TEAMID1234", "KEYID12345", pem)
|
|
|
|
|
assert reused == token
|
|
|
|
|
assert apns._token_state["current"]["token"] == token
|
|
|
|
|
|
|
|
|
|
|
2026-07-31 22:47:20 +02:00
|
|
|
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)
|
2026-08-29 23:28:49 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_apns_parse_registration_accepts_client_id_and_normalizes_token():
|
|
|
|
|
from devplacepy.push import providers
|
|
|
|
|
|
|
|
|
|
apns = providers.PROVIDERS["apns"]
|
|
|
|
|
token = "A1B2C3D4" * 8
|
|
|
|
|
assert apns.parse_registration(
|
|
|
|
|
{"token": f"<{token[:8]} {token[8:]}>", "client_id": " device-1 "}
|
|
|
|
|
) == {"token": token.lower(), "client_id": "device-1"}
|
|
|
|
|
assert apns.parse_registration({"token": token, "client_id": ""}) == {
|
|
|
|
|
"token": token.lower()
|
|
|
|
|
}
|
|
|
|
|
assert apns.parse_registration({"token": token, "client_id": {"uid": "x"}}) is None
|
|
|
|
|
assert apns.parse_registration({"token": token, "client_id": "x" * 200}) is None
|
|
|
|
|
assert apns.parse_registration({"client_id": "device-1"}) is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_apns_client_config_advertises_environment(monkeypatch):
|
|
|
|
|
from devplacepy.push import providers
|
|
|
|
|
from devplacepy.push.providers import apns
|
|
|
|
|
|
|
|
|
|
_apns_settings(monkeypatch, **{apns.ENVIRONMENT_KEY: "sandbox"})
|
|
|
|
|
assert providers.PROVIDERS["apns"].client_config() == {"environment": "sandbox"}
|
|
|
|
|
_apns_settings(monkeypatch, **{apns.ENVIRONMENT_KEY: "nonsense"})
|
|
|
|
|
assert providers.PROVIDERS["apns"].client_config() == {"environment": "production"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_apns_stamp_registration_sets_environment(monkeypatch):
|
|
|
|
|
from devplacepy.push import providers
|
|
|
|
|
from devplacepy.push.providers import apns
|
|
|
|
|
|
|
|
|
|
_apns_settings(monkeypatch, **{apns.ENVIRONMENT_KEY: "sandbox"})
|
|
|
|
|
stamped = providers.PROVIDERS["apns"].stamp_registration({"token": "a" * 64})
|
|
|
|
|
assert stamped["environment"] == "sandbox"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_apns_delivery_uses_registration_environment(monkeypatch):
|
|
|
|
|
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",
|
|
|
|
|
apns.ENVIRONMENT_KEY: "production",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
seen = {}
|
|
|
|
|
|
|
|
|
|
def handler(request):
|
|
|
|
|
seen["url"] = str(request.url)
|
|
|
|
|
return __import__("httpx").Response(200, json={})
|
|
|
|
|
|
|
|
|
|
async def run():
|
|
|
|
|
import httpx
|
|
|
|
|
|
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
|
|
|
async with httpx.AsyncClient(transport=transport) as client:
|
|
|
|
|
return await providers.PROVIDERS["apns"].deliver(
|
|
|
|
|
client,
|
|
|
|
|
{"token": "a" * 64, "environment": "sandbox"},
|
|
|
|
|
"{}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
from tests.conftest import run_async
|
|
|
|
|
|
|
|
|
|
outcome = run_async(run())
|
|
|
|
|
assert outcome.status == providers.ACCEPTED
|
|
|
|
|
assert seen["url"].startswith("https://api.sandbox.push.apple.com/3/device/")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_apns_delivery_client_is_plain_http2():
|
|
|
|
|
import httpx
|
|
|
|
|
from devplacepy.curl_transport import CurlTransport
|
|
|
|
|
from devplacepy.push.providers import apns
|
|
|
|
|
from tests.conftest import run_async
|
|
|
|
|
|
|
|
|
|
client = apns.gateway_client(5.0)
|
|
|
|
|
assert isinstance(client, httpx.AsyncClient)
|
|
|
|
|
assert not isinstance(client._transport, CurlTransport)
|
|
|
|
|
run_async(client.aclose())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_apns_delivery_client_persists_across_calls():
|
|
|
|
|
from devplacepy.push import providers
|
|
|
|
|
from devplacepy.push.providers import apns
|
|
|
|
|
from tests.conftest import run_async
|
|
|
|
|
|
|
|
|
|
provider = providers.PROVIDERS["apns"]
|
|
|
|
|
assert provider.closes_delivery_client() is False
|
|
|
|
|
run_async(apns.close_client())
|
|
|
|
|
try:
|
|
|
|
|
first = provider.delivery_client(5.0)
|
|
|
|
|
second = provider.delivery_client(5.0)
|
|
|
|
|
assert first is second
|
|
|
|
|
assert not first.is_closed
|
|
|
|
|
run_async(apns.close_client())
|
|
|
|
|
assert first.is_closed
|
|
|
|
|
third = provider.delivery_client(5.0)
|
|
|
|
|
assert third is not first
|
|
|
|
|
finally:
|
|
|
|
|
run_async(apns.close_client())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_webpush_closes_delivery_client_by_default():
|
|
|
|
|
from devplacepy.push import providers
|
|
|
|
|
|
|
|
|
|
provider = providers.PROVIDERS["webpush"]
|
|
|
|
|
assert provider.closes_delivery_client() is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_store_upserts_by_client_id_and_revives_dead_tokens(local_db):
|
|
|
|
|
from devplacepy.push import store
|
|
|
|
|
|
|
|
|
|
user_uid = "user-apns-1"
|
|
|
|
|
token = "a" * 64
|
|
|
|
|
write = store.register(user_uid, "apns", {"token": token, "client_id": "phone"})
|
|
|
|
|
assert write.created is True
|
|
|
|
|
assert write.revived is False
|
|
|
|
|
assert write.probe is True
|
|
|
|
|
|
|
|
|
|
same = store.register(user_uid, "apns", {"token": token, "client_id": "phone"})
|
|
|
|
|
assert same.created is False
|
|
|
|
|
assert same.revived is False
|
|
|
|
|
assert same.record["uid"] == write.record["uid"]
|
|
|
|
|
|
|
|
|
|
store.mark_dead(write.record["id"])
|
|
|
|
|
revived = store.register(user_uid, "apns", {"token": token, "client_id": "phone"})
|
|
|
|
|
assert revived.created is False
|
|
|
|
|
assert revived.revived is True
|
|
|
|
|
assert revived.record["deleted_at"] is None
|
|
|
|
|
assert len(store.active_for_user(user_uid)) == 1
|
|
|
|
|
|
|
|
|
|
rotated = store.register(
|
|
|
|
|
user_uid, "apns", {"token": "b" * 64, "client_id": "phone"}
|
|
|
|
|
)
|
|
|
|
|
assert rotated.created is False
|
|
|
|
|
assert rotated.record["token"] == "b" * 64
|
|
|
|
|
assert rotated.record["uid"] == write.record["uid"]
|
|
|
|
|
assert len(store.active_for_user(user_uid)) == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_store_revives_token_only_registration(local_db):
|
|
|
|
|
from devplacepy.push import store
|
|
|
|
|
|
|
|
|
|
user_uid = "user-apns-2"
|
|
|
|
|
token = "c" * 64
|
|
|
|
|
write = store.register(user_uid, "apns", {"token": token})
|
|
|
|
|
store.mark_dead(write.record["id"])
|
|
|
|
|
revived = store.register(user_uid, "apns", {"token": token, "client_id": "phone-2"})
|
|
|
|
|
assert revived.revived is True
|
|
|
|
|
assert revived.record["client_id"] == "phone-2"
|
|
|
|
|
assert len(store.active_for_user(user_uid)) == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_store_attaches_client_id_to_existing_token_row(local_db):
|
|
|
|
|
from devplacepy.push import store
|
|
|
|
|
|
|
|
|
|
user_uid = "user-apns-3"
|
|
|
|
|
token = "d" * 64
|
|
|
|
|
first = store.register(user_uid, "apns", {"token": token})
|
|
|
|
|
second = store.register(user_uid, "apns", {"token": token, "client_id": "phone-3"})
|
|
|
|
|
assert second.created is False
|
|
|
|
|
assert second.record["uid"] == first.record["uid"]
|
|
|
|
|
assert second.record["client_id"] == "phone-3"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_store_mark_dead_skips_a_registration_revived_after_confirmation(local_db):
|
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
|
from devplacepy.push import store
|
|
|
|
|
|
|
|
|
|
user_uid = "user-apns-4"
|
|
|
|
|
token = "f" * 64
|
|
|
|
|
write = store.register(user_uid, "apns", {"token": token})
|
|
|
|
|
registered_at = write.record["registered_at"]
|
|
|
|
|
assert registered_at
|
|
|
|
|
|
|
|
|
|
stale_confirmation = (
|
|
|
|
|
datetime.fromisoformat(registered_at) - timedelta(minutes=5)
|
|
|
|
|
).isoformat()
|
|
|
|
|
store.mark_dead(write.record["id"], stale_confirmation)
|
|
|
|
|
assert len(store.active_for_user(user_uid)) == 1
|
|
|
|
|
|
|
|
|
|
fresh_confirmation = (
|
|
|
|
|
datetime.now(timezone.utc) + timedelta(minutes=5)
|
|
|
|
|
).isoformat()
|
|
|
|
|
store.mark_dead(write.record["id"], fresh_confirmation)
|
|
|
|
|
assert store.active_for_user(user_uid) == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_dead_delivery_logs_the_reason(monkeypatch, caplog):
|
|
|
|
|
import logging
|
|
|
|
|
import httpx
|
|
|
|
|
from tests.conftest import run_async
|
|
|
|
|
from devplacepy.push import providers
|
|
|
|
|
from devplacepy.push.delivery import _deliver_one
|
|
|
|
|
|
|
|
|
|
class _FakeStore:
|
|
|
|
|
def __init__(self):
|
|
|
|
|
self.dead = []
|
|
|
|
|
|
|
|
|
|
def mark_dead(self, registration_id, dead_before=None):
|
|
|
|
|
self.dead.append(registration_id)
|
|
|
|
|
|
|
|
|
|
fake = _FakeStore()
|
|
|
|
|
monkeypatch.setattr("devplacepy.push.delivery.store", fake)
|
|
|
|
|
|
|
|
|
|
async def dead_deliver(client, registration, prepared):
|
|
|
|
|
return providers.Delivery(providers.DEAD, "400 BadDeviceToken")
|
|
|
|
|
|
|
|
|
|
provider = providers.PROVIDERS["apns"]
|
|
|
|
|
monkeypatch.setattr(provider, "deliver", dead_deliver)
|
|
|
|
|
|
|
|
|
|
async def run():
|
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
|
|
|
return await _deliver_one(
|
|
|
|
|
provider, client, {"id": 9, "token": "a" * 64}, "{}", "user-x"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
|
|
|
outcome = run_async(run())
|
|
|
|
|
assert outcome.status == providers.DEAD
|
|
|
|
|
assert fake.dead == [9]
|
|
|
|
|
assert "400 BadDeviceToken" in caplog.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_notify_user_opens_apns_gateway_not_stealth(monkeypatch, local_db):
|
|
|
|
|
import httpx
|
|
|
|
|
from tests.conftest import run_async
|
|
|
|
|
from devplacepy.push import store
|
|
|
|
|
from devplacepy.push.delivery import notify_user
|
|
|
|
|
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",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
store.register("user-gw", "apns", {"token": "e" * 64, "environment": "production"})
|
|
|
|
|
opened = []
|
|
|
|
|
|
|
|
|
|
def fake_gateway(timeout):
|
|
|
|
|
opened.append(timeout)
|
|
|
|
|
return httpx.AsyncClient(
|
|
|
|
|
transport=httpx.MockTransport(lambda request: httpx.Response(200, json={}))
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def fail_stealth(**kwargs):
|
|
|
|
|
raise AssertionError("APNs must not use stealth")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(apns, "gateway_client", fake_gateway)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"devplacepy.stealth.stealth_async_client", fail_stealth
|
|
|
|
|
)
|
|
|
|
|
run_async(apns.close_client())
|
|
|
|
|
try:
|
|
|
|
|
run_async(notify_user("user-gw", {"title": "t", "message": "m"}))
|
|
|
|
|
finally:
|
|
|
|
|
run_async(apns.close_client())
|
|
|
|
|
assert opened
|