Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions changelog.d/tsk-42q2qf-apns-token-reuse-and-410.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
### Fixed
- Apple push no longer mints a fresh provider token for every notification: one token is now cached and reminted on a 50-minute timer, so a burst of notifications can no longer trip Apple's `TooManyProviderTokenUpdates` cap and get pushes refused for the whole account (tsk-42q2qf).
- A `410 Unregistered` from Apple now clears the dead push token from the device instead of being reported as a generic delivery failure, so an uninstalled or re-provisioned device is no longer pushed to forever. The device itself stays paired and visible; it simply has no push token until it registers a new one.
- Every refused push now logs Apple's own `reason` and the `apns-id`, so a refusal can be diagnosed instead of appearing as an unexplained non-delivery. An expired provider token also forces an immediate remint rather than waiting out the refresh timer.
- `InvalidProviderToken` (a rotated signing key, or an otherwise-unparseable cached token) now also forces an immediate remint, the same as `ExpiredProviderToken`, instead of refusing every push for the rest of the 50-minute cache window.
- The cached provider token's `iat` can no longer regress after a backward wall-clock step (a bad NTP correction): it is floored at the previous `iat`, so Apple's own clock cannot see the token as older than the real elapsed time and reject it before this cache's refresh timer would have fired.
2 changes: 1 addition & 1 deletion docs/design/whisplay-pocket-interface-spike.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ permanently is a different product from one that wakes on push.**
the same shape, just client-side on the Pi.
- **Wake-on-push** (the "wakes on push" end of the spectrum) already exists for
the device class — but only APNs / UnifiedPush for ios/watchos/android
(`notifications_push.py:388-429` `send_device_push`, branching on
(`notifications_push.py:417-466` `send_device_push`, branching on
`device["platform"]` in `routes/devices.py`). A Pi has neither push endpoint,
so **polling is the only delivery mode available to it today**; the device
bearer has no push token registered and there is no `linux`/`embedded` platform
Expand Down
221 changes: 221 additions & 0 deletions tests/push/test_apns.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import base64
import json
import httpx
import pytest
Expand Down Expand Up @@ -109,6 +110,226 @@ async def test_null_sender_aclose_is_noop():
assert await NullApnsSender().aclose() is None


# ---------------------------------------------------------------------------
# tsk-42q2qf: provider-token reuse + 410 Unregistered handling
# ---------------------------------------------------------------------------


def _test_key_pem() -> str:
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import serialization

key = ec.generate_private_key(ec.SECP256R1())
return key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
).decode()


def _counting_mint(monkeypatch) -> list[int]:
"""Replace build_apns_jwt with a counting passthrough; returns the counter."""
from tinyagentos.push import apns as apns_mod

real = apns_mod.build_apns_jwt
mints = [0]

def counted(**kwargs):
mints[0] += 1
return real(**kwargs)

monkeypatch.setattr(apns_mod, "build_apns_jwt", counted)
return mints


def _decode_iat(jwt: str) -> int:
"""Decode the `iat` claim out of a JWT built by build_apns_jwt."""
payload_b64 = jwt.split(".")[1]
padded = payload_b64 + "=" * (-len(payload_b64) % 4)
return json.loads(base64.urlsafe_b64decode(padded))["iat"]


def _sender_with(handler, pem: str):
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
sender = HttpApnsSender(
key_pem=pem, key_id="KID", team_id="TID", bundle_id="com.taos.app",
host="api.push.apple.com", client=client,
)
return sender, client


@pytest.mark.asyncio
async def test_provider_token_is_reused_across_pushes(monkeypatch):
# Apple caps provider-token GENERATION: minting one per push earns
# 403 TooManyProviderTokenUpdates and refuses pushes account-wide, so a
# burst must reuse one cached token rather than mint per request.
mints = _counting_mint(monkeypatch)
auths = set()

def handler(req: httpx.Request) -> httpx.Response:
auths.add(req.headers.get("authorization"))
return httpx.Response(200)

sender, client = _sender_with(handler, _test_key_pem())
for _ in range(50):
assert await sender.send("devtoken", {"aps": {}}) is True
assert mints[0] == 1, f"expected 1 JWT mint across 50 pushes, got {mints[0]}"
assert len(auths) == 1
await client.aclose()


@pytest.mark.asyncio
async def test_provider_token_refreshes_after_the_window(monkeypatch):
# The cached token must still be refreshed on a timer: Apple expires a
# provider token after an hour, so a long-lived process that never reminted
# would eventually push with a dead token.
from tinyagentos.push import apns as apns_mod

mints = _counting_mint(monkeypatch)
clock = [1_700_000_000.0]
monkeypatch.setattr(apns_mod.time, "time", lambda: clock[0])

sender, client = _sender_with(lambda req: httpx.Response(200), _test_key_pem())
await sender.send("devtoken", {"aps": {}})
clock[0] += 10 * 60
await sender.send("devtoken", {"aps": {}})
assert mints[0] == 1, f"expected 1 mint 10 minutes in, got {mints[0]}"

clock[0] += 55 * 60
await sender.send("devtoken", {"aps": {}})
assert mints[0] == 2, f"expected a refresh past the window, got {mints[0]} mints"
await client.aclose()


@pytest.mark.asyncio
async def test_410_raises_unregistered_carrying_reason_and_apns_id():
# 410 Unregistered is Apple's permanent "this device token is dead" signal.
# Collapsing it into a plain False means the token is retried forever, so
# the sender must raise a distinguishable error the caller can prune on.
from tinyagentos.push.apns import ApnsUnregistered

def handler(req: httpx.Request) -> httpx.Response:
return httpx.Response(
410,
json={"reason": "Unregistered", "timestamp": 1700000000000},
headers={"apns-id": "AAAA-BBBB"},
)

sender, client = _sender_with(handler, _test_key_pem())
with pytest.raises(ApnsUnregistered) as excinfo:
await sender.send("deadtoken", {"aps": {}})
assert excinfo.value.reason == "Unregistered"
assert excinfo.value.apns_id == "AAAA-BBBB"
assert excinfo.value.push_token == "deadtoken"
await client.aclose()


@pytest.mark.asyncio
async def test_failure_reason_is_surfaced_in_logs(caplog):
# Neither apns-id nor Apple's own `reason` was ever logged, so a refusal was
# indistinguishable from any other non-200 and could not be diagnosed.
def handler(req: httpx.Request) -> httpx.Response:
return httpx.Response(
410, json={"reason": "Unregistered"}, headers={"apns-id": "AAAA-BBBB"},
)

sender, client = _sender_with(handler, _test_key_pem())
with caplog.at_level("WARNING", logger="tinyagentos.push.apns"):
try:
await sender.send("deadtoken", {"aps": {}})
except Exception:
pass
assert "Unregistered" in caplog.text
assert "AAAA-BBBB" in caplog.text
await client.aclose()


@pytest.mark.asyncio
async def test_non_410_refusal_logs_reason_and_returns_false(caplog):
# A retryable refusal (bad payload, bad topic) stays a plain False, but the
# reason must still reach the log.
def handler(req: httpx.Request) -> httpx.Response:
return httpx.Response(400, json={"reason": "BadDeviceToken"})

sender, client = _sender_with(handler, _test_key_pem())
with caplog.at_level("WARNING", logger="tinyagentos.push.apns"):
assert await sender.send("devtoken", {"aps": {}}) is False
assert "BadDeviceToken" in caplog.text
await client.aclose()


@pytest.mark.asyncio
async def test_expired_provider_token_forces_a_remint(monkeypatch):
# Caching introduces a new failure mode: if the cached token expires early
# (clock skew), every push would be refused until the refresh timer fired.
# Apple's ExpiredProviderToken must therefore invalidate the cache at once.
mints = _counting_mint(monkeypatch)
statuses = [403, 200, 200]

def handler(req: httpx.Request) -> httpx.Response:
if statuses.pop(0) == 403:
return httpx.Response(403, json={"reason": "ExpiredProviderToken"})
return httpx.Response(200)

sender, client = _sender_with(handler, _test_key_pem())
assert await sender.send("devtoken", {"aps": {}}) is False
assert await sender.send("devtoken", {"aps": {}}) is True
assert await sender.send("devtoken", {"aps": {}}) is True
# Exactly one extra mint: the expiry invalidates the cache once, and the
# replacement token is then reused like any other.
assert mints[0] == 2, f"expected exactly one remint after ExpiredProviderToken, got {mints[0]}"
await client.aclose()


@pytest.mark.asyncio
async def test_invalid_provider_token_forces_a_remint(monkeypatch):
# InvalidProviderToken is just as permanent as ExpiredProviderToken (a
# rotated signing key, or a cached token that is otherwise unparseable):
# every push would be refused for the rest of the 50-minute cache window
# unless this also invalidates the cache immediately.
mints = _counting_mint(monkeypatch)
statuses = [403, 200, 200]

def handler(req: httpx.Request) -> httpx.Response:
if statuses.pop(0) == 403:
return httpx.Response(403, json={"reason": "InvalidProviderToken"})
return httpx.Response(200)

sender, client = _sender_with(handler, _test_key_pem())
assert await sender.send("devtoken", {"aps": {}}) is False
assert await sender.send("devtoken", {"aps": {}}) is True
assert await sender.send("devtoken", {"aps": {}}) is True
assert mints[0] == 2, f"expected exactly one remint after InvalidProviderToken, got {mints[0]}"
await client.aclose()


@pytest.mark.asyncio
async def test_provider_token_iat_never_regresses_after_backward_clock_step(monkeypatch):
# A wall clock that steps backward (a bad NTP correction) must not pin the
# next token's iat to the regressed time. Apple checks iat against its OWN
# correct clock: a regressed iat combined with a full fresh 50-minute local
# cache window can let this cache keep reusing the token until Apple's real
# elapsed-since-iat time is already past the true one-hour limit, well
# before the local refresh timer would ever fire.
from tinyagentos.push import apns as apns_mod

clock = [1_700_000_000.0]
monkeypatch.setattr(apns_mod.time, "time", lambda: clock[0])

sender, client = _sender_with(lambda req: httpx.Response(200), _test_key_pem())
await sender.send("devtoken", {"aps": {}})
iat1 = _decode_iat(sender._jwt)
assert iat1 == int(clock[0])

# The clock steps backward by 15 minutes and never corrects (a permanent
# skew), forcing an immediate remint (age goes negative).
clock[0] -= 15 * 60
await sender.send("devtoken", {"aps": {}})
iat2 = _decode_iat(sender._jwt)
assert iat2 >= iat1, f"iat regressed from {iat1} to {iat2} after a backward clock step"
await client.aclose()


# ---------------------------------------------------------------------------
# tsk-cf7wzc: image + actions wiring for the native decision shell
# ---------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion tests/push/test_unifiedpush.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ async def list_for_user(self, user_id):
apns_sender=FakeApns(),
up_sender=FakeUP(),
)
assert result == {"sent": 0, "failed": 0, "skipped": 0}
assert result == {"sent": 0, "failed": 0, "skipped": 0, "removed": 0}


@pytest.mark.asyncio
Expand Down
89 changes: 88 additions & 1 deletion tests/test_notifications_push.py
Original file line number Diff line number Diff line change
Expand Up @@ -735,7 +735,7 @@ async def list_for_user(self, user_id):
apns_sender=FakeApns(),
up_sender=FakeUP(),
)
assert result == {"sent": 0, "failed": 0, "skipped": 0}
assert result == {"sent": 0, "failed": 0, "skipped": 0, "removed": 0}

async def test_no_push_token_device_is_skipped(self):
class FakeApns:
Expand Down Expand Up @@ -769,6 +769,93 @@ async def list_for_user(self, user_id):
assert result["sent"] == 1
assert result["skipped"] == 1

# -----------------------------------------------------------------------
# tsk-42q2qf: a 410 Unregistered must delete the dead device token
# -----------------------------------------------------------------------

async def test_apns_410_clears_the_dead_push_token(self, tmp_path):
# 410 Unregistered is permanent: the token must be dropped from the
# store, otherwise every later notification retries a dead device
# forever. Uses the real DeviceStore so the SQL is exercised.
from tinyagentos.device_store import DeviceStore
from tinyagentos.push.apns import ApnsUnregistered

store = DeviceStore(tmp_path / "devices.db")
await store.init()
try:
dev = await store.register(
user_id="u1", platform="ios", push_token="deadtoken"
)

class FakeApns:
async def send(self, push_token, payload, *, topic=None):
raise ApnsUnregistered(push_token, apns_id="AAAA", reason="Unregistered")

async def aclose(self):
pass

class FakeUP:
async def send(self, *args, **kwargs):
return True

async def aclose(self):
pass

row = {
"id": 1, "title": "Hi", "message": "", "source": "system",
"user_id": "u1", "data": {},
}
result = await send_device_push(
row, device_store=store, apns_sender=FakeApns(), up_sender=FakeUP(),
)
assert result["removed"] == 1
assert result["failed"] == 0
after = await store.get(dev["device_id"])
assert after is not None, "the device row itself must survive"
assert after["push_token"] == "", "expected the dead token removed from the store"
finally:
await store.close()

async def test_apns_410_leaves_a_freshly_re_registered_token_alone(self, tmp_path):
# A device that re-registered between fan-out and the 410 response must
# keep its NEW token: the prune is scoped to the token that actually
# failed, so a live registration is not collateral damage.
from tinyagentos.device_store import DeviceStore
from tinyagentos.push.apns import ApnsUnregistered

store = DeviceStore(tmp_path / "devices.db")
await store.init()
try:
dev = await store.register(
user_id="u1", platform="ios", push_token="oldtoken"
)

class FakeApns:
async def send(self, push_token, payload, *, topic=None):
await store.update_push_token(dev["device_id"], "freshtoken")
raise ApnsUnregistered(push_token, apns_id="AAAA", reason="Unregistered")

async def aclose(self):
pass

class FakeUP:
async def send(self, *args, **kwargs):
return True

async def aclose(self):
pass

row = {
"id": 1, "title": "Hi", "message": "", "source": "system",
"user_id": "u1", "data": {},
}
await send_device_push(
row, device_store=store, apns_sender=FakeApns(), up_sender=FakeUP(),
)
after = await store.get(dev["device_id"])
assert after["push_token"] == "freshtoken"
finally:
await store.close()
async def test_ios_decision_sets_category_mutable_content_and_actions(self):
# tsk-cf7wzc: an approve_deny decision must reach iOS with the
# DECISION_APPROVE_DENY category so the native shell maps it to a
Expand Down
17 changes: 17 additions & 0 deletions tinyagentos/device_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,23 @@ async def update_push_token(self, device_id: str, push_token: str) -> dict | Non
await self._db.commit()
return await self.get(device_id)

async def clear_push_token(self, device_id: str, push_token: str) -> bool:
"""Drop a push token the push service reported as permanently dead.

Scoped to the exact token that failed, so a device that re-registered a
fresh token between the fan-out and the 410 response keeps the new one.
The device row itself is kept: the device is still paired and still
visible to its owner, it simply has no deliverable push token until it
registers another. Returns True when a row was cleared.
"""
assert self._db is not None
cur = await self._db.execute(
"UPDATE devices SET push_token = '' WHERE device_id = ? AND push_token = ?",
(device_id, push_token),
)
await self._db.commit()
return cur.rowcount > 0

async def touch(self, device_id: str) -> None:
assert self._db is not None
await self._db.execute(
Expand Down
Loading
Loading