Merge branch 'master' into typosaurus/ticket-150
DevPlace CI / test (pull_request) Failing after 1h22m9s
DevPlace CI / test (pull_request) Failing after 1h22m9s
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