diff --git a/docs/design/push.md b/docs/design/push.md index df76f5ee6..9d9ba8bc3 100644 --- a/docs/design/push.md +++ b/docs/design/push.md @@ -5,12 +5,13 @@ The journal's push role is deliberately narrow: - Keep a device registry keyed by each paired device's link fingerprint. +- Self-provision a reach relay token from the hosted services portal. - Provide a test-push endpoint that goes through the hosted relay. - Relay sol-initiated chat push events to the hosted service through `portal_dispatch`. -The journal does not contact Apple. Device delivery, relay-to-service token -plumbing, and platform-specific delivery details live outside this repository. +The journal does not contact Apple. Device delivery and platform-specific +delivery details live outside this repository. ## Module Layout @@ -21,6 +22,7 @@ plumbing, and platform-specific delivery details live outside this repository. | `solstone/think/push/triggers.py` | Relay-only callosum handlers for direct chat requests and chat lifecycle events, plus the nudge-log writer used by sol-initiated chat accounting. | | `solstone/think/push/runtime.py` | Runtime singleton that starts a callosum listener and routes each message through the two push trigger handlers. | | `solstone/think/push/portal_dispatch.py` | HTTP relay client for the hosted `/push/dispatch` and `/push/dedup` endpoints. | +| `solstone/think/push/reach.py` | Reach relay-token client. Enrolls with the hosted reach endpoint, stores the opaque token under journal config, and refreshes before dispatch. | `push_devices.json` stores: @@ -98,7 +100,7 @@ Response: } ``` -`relay_available` is true when an approved scout dispatch token is present. +`relay_available` is true when a reach relay token is present in journal config. Fingerprints and full tokens are not exposed. ### `POST /api/push/test` @@ -109,11 +111,12 @@ Optional request body: {"body": "This is a test notification."} ``` -The handler requires an approved dispatch token. It creates a +The handler requires at least one registered device. It creates a `push-test-` request id and calls `dispatch_via_portal(...)` with the test -summary and sol-chat-request category. If no dispatch token is present, the -route returns `503 feature_unavailable` with detail `push relay unavailable`. -If the relay call fails, it returns `503 feature_unavailable` with detail +summary and sol-chat-request category. Dispatch self-provisions or refreshes the +reach relay token as needed. If no devices are registered, the route returns +`503 feature_unavailable` with detail `no devices to reach`. If token enrollment +or the relay call fails, it returns `503 feature_unavailable` with detail `push relay dispatch failed`. Success response: @@ -135,8 +138,8 @@ The direct chat request handler listens for: - `event == KIND_SOL_CHAT_REQUEST` - non-empty `request_id` -With an approved dispatch token, it calls `dispatch_via_portal(request_id, -summary, category)`. +With registered devices, it calls `dispatch_via_portal(request_id, summary, +category)`. Dispatch provisions or refreshes the reach relay token internally. `handle_chat_lifecycle` listens for: @@ -144,8 +147,9 @@ summary, category)`. - `event in {KIND_OWNER_CHAT_OPEN, KIND_OWNER_CHAT_DISMISSED}` - non-empty `request_id` -With an approved dispatch token, it calls -`dispatch_dedup_via_portal(request_id, action=event)`. +With registered devices, it calls +`dispatch_dedup_via_portal(request_id, action=event)`. Dispatch provisions or +refreshes the reach relay token internally. ## Nudge Log @@ -165,7 +169,7 @@ Successful relay row: } ``` -No dispatch token row: +No devices row: ```json { @@ -174,11 +178,11 @@ No dispatch token row: "dedupe_key": "req-1", "category": "notice", "outcome": "skipped", - "reason": "no_dispatch_token" + "reason": "no_devices" } ``` -Relay unavailable row: +Relay unavailable row, including reach-token enrollment or refresh failure: ```json { @@ -194,18 +198,92 @@ Relay unavailable row: Lifecycle rows use `kind == "sol_chat_lifecycle_push"` and store the lifecycle event name in `category`. +Chat-fold rows may also skip with `reason == "owner_viewing_chat"` when the +owner already has a recent open chat view. There is no token-specific skipped +reason; token acquisition failures collapse into `portal_unavailable`. + +## Reach Token Enrollment + +The reach relay token is opaque to the journal. The journal stores and forwards +it as a Bearer token and never decodes it. State lives under +`services.push.reach_token` in `journal/config/journal.json`: + +```json +{ + "token": "", + "instance_id": "", + "expires_at": "2026-06-20T12:00:00Z", + "expires_epoch": 1781956800 +} +``` + +`expires_at` is the display/source string returned by the service. +`expires_epoch` is derived from `expires_at` for integer refresh checks. The +legacy relay-token string path is not read. + +Enrollment is best-effort on successful device registration and refreshed on +dispatch when the stored token is expired, malformed, for another instance, or +inside the one-hour refresh margin. The journal POSTs to: + +```text +POST {portal_base_url()}/reach/push/relay-token +``` + +Request body: + +```json +{ + "instance_id": "", + "ca_pubkey": "", + "assertion": "" +} +``` + +Assertion header: + +```json +{"alg":"ES256","typ":"home-reach"} +``` + +Assertion claims: + +```json +{ + "iss": "home:", + "aud": "solstone-reach", + "scope": "push.relay.enroll", + "instance_id": "", + "iat": 1770000000, + "exp": 1770000240, + "jti": "" +} +``` + +Response body: + +```json +{ + "token": "", + "token_type": "Bearer", + "expires_at": "2026-06-20T12:00:00Z", + "expires_in": 86400, + "instance_id": "" +} +``` + ## Domain Ownership Per AGENTS.md L2, `solstone/think/push/devices.py` is the sole writer for `journal/config/push_devices.json`. `solstone/think/push/triggers.py` is the sole writer for `journal/push/nudge_log.jsonl`. -`solstone/convey/push.py` validates HTTP input and delegates mutations to -`devices.py`. It must not write journal files directly. +`solstone/convey/push.py` validates HTTP input and delegates mutations to the +think layer: device registry changes go through `devices.py`, and reach token +state changes go through `reach.py` via `journal_config`. It must not write +journal files directly. ## Out Of Scope -- Relay-to-service token plumbing beyond the approved dispatch token check. - Per-device body encryption. - A `device_pubkey` column or migration. - Delivery-provider behavior owned by the hosted relay. diff --git a/solstone/convey/push.py b/solstone/convey/push.py index f4750c3e7..17111e4f6 100644 --- a/solstone/convey/push.py +++ b/solstone/convey/push.py @@ -25,7 +25,7 @@ from solstone.think.push.devices import ( status_view, ) from solstone.think.push.portal_dispatch import dispatch_via_portal -from solstone.think.push.relay_auth import push_relay_token +from solstone.think.push.reach import ensure_reach_token, read_reach_token push_bp = Blueprint("push", __name__, url_prefix="/api/push") @@ -100,6 +100,7 @@ def register_push_device(): environment=environment, platform=platform, ) + ensure_reach_token() return jsonify({"registered": True, "device_count": count}) @@ -122,7 +123,7 @@ def push_status(): return jsonify( { "device_count": len(devices), - "relay_available": bool(push_relay_token()), + "relay_available": bool(read_reach_token()), "devices": [status_view(device) for device in devices], } ) @@ -133,12 +134,6 @@ def send_push_test(): body, error = _optional_json_object() if error is not None: return error - if not push_relay_token(): - return error_response( - FEATURE_UNAVAILABLE, - status=503, - detail="push relay unavailable", - ) if not load_devices(): return error_response( FEATURE_UNAVAILABLE, diff --git a/solstone/think/link/ca.py b/solstone/think/link/ca.py index 5afadc777..6508569c3 100644 --- a/solstone/think/link/ca.py +++ b/solstone/think/link/ca.py @@ -214,6 +214,35 @@ def mint_attestation( return f"{header_b64}.{payload_b64}.{sig_b64}" +def mint_reach_assertion( + ca: LoadedCa, + instance_id: str, + *, + now: int | None = None, +) -> str: + """Mint an ES256 assertion JWT for reach push-relay enrollment.""" + iat = now if now is not None else int(time.time()) + exp = iat + ATTESTATION_LIFETIME_SECONDS + header = {"alg": "ES256", "typ": "home-reach"} + claims = { + "iss": f"home:{instance_id}", + "aud": "solstone-reach", + "scope": "push.relay.enroll", + "instance_id": instance_id, + "iat": iat, + "exp": exp, + "jti": str(uuid.uuid4()), + } + header_b64 = _b64url(json.dumps(header, separators=(",", ":")).encode("utf-8")) + payload_b64 = _b64url(json.dumps(claims, separators=(",", ":")).encode("utf-8")) + signing_input = f"{header_b64}.{payload_b64}".encode("ascii") + der_sig = ca.private_key.sign(signing_input, ec.ECDSA(hashes.SHA256())) + r, s = decode_dss_signature(der_sig) + raw_sig = r.to_bytes(32, "big") + s.to_bytes(32, "big") + sig_b64 = _b64url(raw_sig) + return f"{header_b64}.{payload_b64}.{sig_b64}" + + def cert_fingerprint(cert_pem: str | bytes) -> str: """Compute `sha256:` over the DER form of a PEM-encoded cert.""" pem_bytes = cert_pem.encode("utf-8") if isinstance(cert_pem, str) else cert_pem diff --git a/solstone/think/link/paths.py b/solstone/think/link/paths.py index 80d7c5ac5..c6b4a7179 100644 --- a/solstone/think/link/paths.py +++ b/solstone/think/link/paths.py @@ -126,7 +126,7 @@ class LinkState: @classmethod def load(cls, *, default_label: str = "solstone") -> LinkState | None: """Pure read of `state.json`; None if unprovisioned/unreadable. No write.""" - path = state_path() + path = Path(get_journal()) / "link" / "state.json" if not path.exists(): return None try: diff --git a/solstone/think/push/portal_dispatch.py b/solstone/think/push/portal_dispatch.py index 028d78de6..db5d4382c 100644 --- a/solstone/think/push/portal_dispatch.py +++ b/solstone/think/push/portal_dispatch.py @@ -12,7 +12,7 @@ from urllib import request as urllib_request from urllib.error import HTTPError, URLError from solstone.think.push.devices import load_devices, remove_devices_by_tokens -from solstone.think.push.relay_auth import push_relay_token +from solstone.think.push.reach import ensure_reach_token from solstone.think.services.portal_client import portal_base_url, request_headers logger = logging.getLogger(__name__) @@ -46,14 +46,14 @@ def _prune_revoked(payload: dict) -> None: def dispatch_via_portal(*, request_id: str, summary: str, category: str) -> dict | None: - dispatch_token = push_relay_token() - if not dispatch_token: - return None - devices = _outbound_devices() if not devices: return None + dispatch_token = ensure_reach_token() + if not dispatch_token: + return None + body = json.dumps( { "request_id": request_id, @@ -125,14 +125,14 @@ def dispatch_via_portal(*, request_id: str, summary: str, category: str) -> dict def dispatch_dedup_via_portal(*, request_id: str, action: str) -> dict | None: - dispatch_token = push_relay_token() - if not dispatch_token: - return None - devices = _outbound_devices() if not devices: return None + dispatch_token = ensure_reach_token() + if not dispatch_token: + return None + body = json.dumps( {"request_id": request_id, "action": action, "devices": devices} ).encode("utf-8") diff --git a/solstone/think/push/reach.py b/solstone/think/push/reach.py new file mode 100644 index 000000000..e11d2b2df --- /dev/null +++ b/solstone/think/push/reach.py @@ -0,0 +1,233 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Reach relay-token provisioning for push dispatch.""" + +from __future__ import annotations + +import datetime as dt +import json +import logging +import socket +import time +from typing import Any +from urllib import request as urllib_request +from urllib.error import HTTPError, URLError + +from solstone.think.journal_config import ( + hold_config_lock, + read_journal_config, + write_journal_config, +) +from solstone.think.link.ca import LoadedCa, load_or_generate_ca, mint_reach_assertion +from solstone.think.link.paths import LinkState, ca_dir +from solstone.think.services.portal_client import portal_base_url, request_headers + +logger = logging.getLogger(__name__) + +REACH_REFRESH_MARGIN_SECONDS = 3600 +_TIMEOUT_SECONDS = 10 + + +def read_reach_token() -> str | None: + """Return the stored reach relay token, if present.""" + state = _read_stored_state() + return _state_token(state) + + +def ensure_reach_token() -> str | None: + """Return a usable reach relay token, provisioning or refreshing when needed.""" + now = int(time.time()) + try: + state = _read_stored_state() + except Exception as exc: + logger.warning( + "reach relay token config read failed: error=%s", type(exc).__name__ + ) + state = None + + if _state_usable(state, now): + return _state_token(state) + + try: + link_state = LinkState.load() + except Exception as exc: + logger.warning( + "reach relay token identity read failed: error=%s", type(exc).__name__ + ) + return None + if link_state is None: + return None + + try: + ca = load_or_generate_ca(ca_dir()) + new_state = _request_reach_token(link_state.instance_id, ca) + except Exception as exc: + logger.warning("reach relay token refresh failed: error=%s", type(exc).__name__) + new_state = None + + if new_state is None: + return _state_unexpired_token(state, now) + new_token = _state_token(new_state) + if new_token is None: + return _state_unexpired_token(state, now) + + try: + with hold_config_lock(): + config = read_journal_config() + services = config.setdefault("services", {}) + if not isinstance(services, dict): + services = {} + config["services"] = services + push = services.setdefault("push", {}) + if not isinstance(push, dict): + push = {} + services["push"] = push + push["reach_token"] = new_state + push.pop("relay_token", None) + write_journal_config(config) + except Exception as exc: + logger.warning( + "reach relay token persistence failed: error=%s", type(exc).__name__ + ) + + return new_token + + +def _request_reach_token(instance_id: str, ca: LoadedCa) -> dict[str, Any] | None: + try: + assertion = mint_reach_assertion(ca, instance_id) + body = json.dumps( + { + "instance_id": instance_id, + "ca_pubkey": ca.pubkey_spki_pem, + "assertion": assertion, + } + ).encode("utf-8") + headers = request_headers("push") + headers.update({"Content-Type": "application/json"}) + request = urllib_request.Request( + f"{portal_base_url()}/reach/push/relay-token", + data=body, + headers=headers, + method="POST", + ) + + with urllib_request.urlopen(request, timeout=_TIMEOUT_SECONDS) as response: + status = int(getattr(response, "status", response.getcode())) + raw_body = response.read() + except HTTPError as exc: + status = int(exc.code) + if 400 <= status < 500: + logger.warning("reach relay token request rejected: status=%s", status) + else: + logger.warning("reach relay token request server error: status=%s", status) + return None + except (URLError, socket.timeout, TimeoutError) as exc: + logger.warning( + "reach relay token transport failure: error=%s", type(exc).__name__ + ) + return None + except Exception as exc: + logger.warning( + "reach relay token transport failure: error=%s", type(exc).__name__ + ) + return None + + if not 200 <= status < 300: + return None + try: + payload = json.loads(raw_body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return None + if not isinstance(payload, dict): + return None + + if payload.get("instance_id") != instance_id: + return None + token = payload.get("token") + if not isinstance(token, str) or not token: + return None + expires_at = payload.get("expires_at") + expires_epoch = _parse_expires_epoch(expires_at) + if expires_epoch is None: + return None + + return { + "token": token, + "instance_id": instance_id, + "expires_at": expires_at, + "expires_epoch": expires_epoch, + } + + +def _read_stored_state() -> object: + config = read_journal_config() + services = config.get("services") + if not isinstance(services, dict): + return None + push = services.get("push") + if not isinstance(push, dict): + return None + return push.get("reach_token") + + +def _state_token(state: object) -> str | None: + if not isinstance(state, dict): + return None + token = state.get("token") + return token if isinstance(token, str) and token else None + + +def _state_usable(state: object, now: int) -> bool: + token = _state_token(state) + if token is None or not isinstance(state, dict): + return False + expires_epoch = state.get("expires_epoch") + if not isinstance(expires_epoch, int) or isinstance(expires_epoch, bool): + return False + instance_id = state.get("instance_id") + if not isinstance(instance_id, str) or instance_id != _current_instance_id(): + return False + return now < expires_epoch - REACH_REFRESH_MARGIN_SECONDS + + +def _state_unexpired_token(state: object, now: int) -> str | None: + token = _state_token(state) + if token is None or not isinstance(state, dict): + return None + expires_epoch = state.get("expires_epoch") + if not isinstance(expires_epoch, int) or isinstance(expires_epoch, bool): + return None + return token if now < expires_epoch else None + + +def _current_instance_id() -> str | None: + try: + link_state = LinkState.load() + except Exception as exc: + logger.warning( + "reach relay token identity read failed: error=%s", type(exc).__name__ + ) + return None + return link_state.instance_id if link_state is not None else None + + +def _parse_expires_epoch(expires_at: object) -> int | None: + if not isinstance(expires_at, str) or not expires_at: + return None + candidate = expires_at[:-1] + "+00:00" if expires_at.endswith("Z") else expires_at + try: + parsed = dt.datetime.fromisoformat(candidate) + except ValueError: + return None + if parsed.tzinfo is None or parsed.utcoffset() != dt.timedelta(0): + return None + return int(parsed.timestamp()) + + +__all__ = [ + "REACH_REFRESH_MARGIN_SECONDS", + "ensure_reach_token", + "read_reach_token", +] diff --git a/solstone/think/push/relay_auth.py b/solstone/think/push/relay_auth.py deleted file mode 100644 index 5b2df61b7..000000000 --- a/solstone/think/push/relay_auth.py +++ /dev/null @@ -1,17 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Credential lookup for the journal push relay.""" - -import os - -from solstone.think.journal_config import read_journal_config - - -def push_relay_token() -> str | None: - block = read_journal_config().get("services", {}).get("push", {}) - token = block.get("relay_token") - if isinstance(token, str) and token: - return token - env_token = os.getenv("PUSH_RELAY_SECRET") - return env_token if env_token else None diff --git a/solstone/think/push/triggers.py b/solstone/think/push/triggers.py index 0cc24580c..38d48e830 100644 --- a/solstone/think/push/triggers.py +++ b/solstone/think/push/triggers.py @@ -22,7 +22,6 @@ from solstone.think.push.portal_dispatch import ( dispatch_dedup_via_portal, dispatch_via_portal, ) -from solstone.think.push.relay_auth import push_relay_token from solstone.think.utils import get_journal logger = logging.getLogger("solstone.push.triggers") @@ -71,19 +70,6 @@ def handle_sol_chat_request(message: dict[str, Any]) -> None: category = str(message.get("category") or "") kind = f"{KIND_SOL_CHAT_REQUEST}_push" - if not push_relay_token(): - _append_nudge_log( - { - "ts": int(time.time()), - "kind": kind, - "dedupe_key": request_id, - "category": category, - "outcome": "skipped", - "reason": "no_relay_token", - } - ) - return - if not load_devices(): _append_nudge_log( { @@ -156,19 +142,6 @@ def handle_chat_fold(message: dict[str, Any]) -> None: ) return - if not push_relay_token(): - _append_nudge_log( - { - "ts": int(time.time()), - "kind": kind, - "dedupe_key": route_id, - "category": FOLD_PUSH_ACTION, - "outcome": "skipped", - "reason": "no_relay_token", - } - ) - return - if not load_devices(): _append_nudge_log( { @@ -227,19 +200,6 @@ def handle_chat_lifecycle(message: dict[str, Any]) -> None: return kind = "sol_chat_lifecycle_push" - if not push_relay_token(): - _append_nudge_log( - { - "ts": int(time.time()), - "kind": kind, - "dedupe_key": request_id, - "category": event, - "outcome": "skipped", - "reason": "no_relay_token", - } - ) - return - if not load_devices(): _append_nudge_log( { diff --git a/tests/link/test_ca.py b/tests/link/test_ca.py index d0832c507..8e09cdd0f 100644 --- a/tests/link/test_ca.py +++ b/tests/link/test_ca.py @@ -8,6 +8,7 @@ from __future__ import annotations import base64 import json import os +import uuid from pathlib import Path import pytest @@ -23,6 +24,7 @@ from solstone.think.link.ca import ( load_ca, load_or_generate_ca, mint_attestation, + mint_reach_assertion, sign_csr, ) @@ -147,6 +149,45 @@ def test_attestation_signed_by_ca_verifies(tmp_path: Path) -> None: ) +def test_reach_assertion_signed_by_ca_verifies(tmp_path: Path) -> None: + ca = generate_ca(tmp_path / "ca") + instance_id = "deadbeef-dead-beef-dead-beefdeadbeef" + + jwt = mint_reach_assertion(ca, instance_id, now=1_745_006_400) + segments = jwt.split(".") + + assert len(segments) == 3 + header_b64, payload_b64, sig_b64 = segments + + header = json.loads(_b64_decode(header_b64)) + payload = json.loads(_b64_decode(payload_b64)) + raw_sig = _b64_decode(sig_b64) + + assert header == {"alg": "ES256", "typ": "home-reach"} + assert payload["iss"] == f"home:{instance_id}" + assert payload["aud"] == "solstone-reach" + assert payload["scope"] == "push.relay.enroll" + assert payload["instance_id"] == instance_id + assert payload["iat"] == 1_745_006_400 + assert payload["exp"] == 1_745_006_640 + assert payload["exp"] - payload["iat"] == 240 + assert uuid.UUID(payload["jti"]) + assert "device_fp" not in payload + + assert len(raw_sig) == 64 + r = int.from_bytes(raw_sig[:32], "big") + s = int.from_bytes(raw_sig[32:], "big") + der_sig = encode_dss_signature(r, s) + + public_key = serialization.load_pem_public_key(ca.pubkey_spki_pem.encode("ascii")) + assert isinstance(public_key, ec.EllipticCurvePublicKey) + public_key.verify( + der_sig, + f"{header_b64}.{payload_b64}".encode("ascii"), + ec.ECDSA(hashes.SHA256()), + ) + + def _b64_decode(value: str) -> bytes: padding = "=" * (-len(value) % 4) return base64.urlsafe_b64decode(value + padding) diff --git a/tests/test_convey_chat_sol_initiated.py b/tests/test_convey_chat_sol_initiated.py index 84e5459cd..39b8d1e3a 100644 --- a/tests/test_convey_chat_sol_initiated.py +++ b/tests/test_convey_chat_sol_initiated.py @@ -172,7 +172,6 @@ def test_sol_request_open_endpoint_broadcast_suppresses_push( assert (tract, kind) == ("chat", KIND_OWNER_CHAT_OPEN) assert kwargs["request_id"] == "req-1" - monkeypatch.setattr(triggers, "push_relay_token", lambda: "tok") monkeypatch.setattr( triggers, "load_devices", diff --git a/tests/test_push_integration.py b/tests/test_push_integration.py index 38cc0f748..614238cb7 100644 --- a/tests/test_push_integration.py +++ b/tests/test_push_integration.py @@ -16,7 +16,6 @@ def _read_log(journal: Path) -> list[dict[str, object]]: def test_push_runtime_relay_dispatch_and_log(journal_copy, monkeypatch): - monkeypatch.setattr(triggers, "push_relay_token", lambda: "tok") monkeypatch.setattr( triggers, "load_devices", diff --git a/tests/test_push_portal_dispatch.py b/tests/test_push_portal_dispatch.py index d162ec3e9..8921a42ec 100644 --- a/tests/test_push_portal_dispatch.py +++ b/tests/test_push_portal_dispatch.py @@ -43,19 +43,9 @@ class FakeResponse: def _setup_journal(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - monkeypatch.delenv("PUSH_RELAY_SECRET", raising=False) monkeypatch.delenv("SERVICES_PORTAL_URL", raising=False) -def _write_relay_config(journal: Path, token: str = "tok") -> None: - path = journal / "config" / "journal.json" - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - json.dumps({"services": {"push": {"relay_token": token}}}), - encoding="utf-8", - ) - - def _register_device( fingerprint: str, token: str, @@ -100,7 +90,7 @@ def test_dispatch_body_includes_normalized_registered_devices( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: _setup_journal(monkeypatch, tmp_path) - _write_relay_config(tmp_path) + monkeypatch.setattr(portal_dispatch, "ensure_reach_token", lambda: "tok") _register_device("fp-1", "a" * 64) _register_device("fp-2", "b" * 64, environment="production") requests = _capture_urlopen(monkeypatch) @@ -137,11 +127,13 @@ def test_dispatch_body_includes_normalized_registered_devices( } -def test_dispatch_uses_push_relay_token_without_scout_config( +def test_dispatch_uses_reach_token_for_authorization( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: _setup_journal(monkeypatch, tmp_path) - _write_relay_config(tmp_path, "relay-config-token") + monkeypatch.setattr( + portal_dispatch, "ensure_reach_token", lambda: "reach-config-token" + ) _register_device("fp-1", "a" * 64) requests = _capture_urlopen(monkeypatch) @@ -152,14 +144,14 @@ def test_dispatch_uses_push_relay_token_without_scout_config( ) assert result == {"ok": True} - assert _headers(requests[0])["authorization"] == "Bearer relay-config-token" + assert _headers(requests[0])["authorization"] == "Bearer reach-config-token" def test_revoked_tokens_prune_matching_devices_only( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: _setup_journal(monkeypatch, tmp_path) - _write_relay_config(tmp_path) + monkeypatch.setattr(portal_dispatch, "ensure_reach_token", lambda: "tok") _register_device("fp-1", "a" * 64) _register_device("fp-2", "b" * 64) _register_device("fp-3", "c" * 64) @@ -214,13 +206,16 @@ def test_dispatch_short_circuits_empty_registry_without_posting( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: _setup_journal(monkeypatch, tmp_path) - _write_relay_config(tmp_path) requests: list[Request] = [] + def fail_ensure() -> str: + raise AssertionError("ensure_reach_token should not be called") + def fail_urlopen(request: Request, timeout: float = 0) -> FakeResponse: requests.append(request) raise AssertionError("urlopen should not be called") + monkeypatch.setattr(portal_dispatch, "ensure_reach_token", fail_ensure) monkeypatch.setattr(portal_dispatch.urllib_request, "urlopen", fail_urlopen) result = portal_dispatch.dispatch_via_portal( @@ -231,21 +226,3 @@ def test_dispatch_short_circuits_empty_registry_without_posting( assert result is None assert requests == [] - - -def test_dispatch_uses_env_fallback_when_config_absent( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - _setup_journal(monkeypatch, tmp_path) - monkeypatch.setenv("PUSH_RELAY_SECRET", "env-relay-token") - _register_device("fp-1", "a" * 64) - requests = _capture_urlopen(monkeypatch) - - result = portal_dispatch.dispatch_via_portal( - request_id="req-1", - summary="hello", - category=APNS_CATEGORY_SOL_CHAT_REQUEST, - ) - - assert result == {"ok": True} - assert _headers(requests[0])["authorization"] == "Bearer env-relay-token" diff --git a/tests/test_push_reach.py b/tests/test_push_reach.py new file mode 100644 index 000000000..85b6df3bc --- /dev/null +++ b/tests/test_push_reach.py @@ -0,0 +1,443 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import base64 +import datetime as dt +import json +import socket +import urllib.error +from pathlib import Path +from types import TracebackType +from typing import Any +from urllib.request import Request + +import pytest + +from solstone.think.journal_config import read_journal_config, write_journal_config +from solstone.think.link import ca as ca_module +from solstone.think.link.ca import load_or_generate_ca +from solstone.think.link.paths import LinkState, ca_dir +from solstone.think.push import reach + +NOW = 1_745_006_400 +EXPIRES_AT = "2026-06-20T12:00:00Z" +EXPIRES_EPOCH = int(dt.datetime(2026, 6, 20, 12, tzinfo=dt.UTC).timestamp()) + + +class FakeResponse: + def __init__(self, body: bytes | dict[str, Any], *, status: int = 200) -> None: + self.status = status + self._body = ( + json.dumps(body).encode("utf-8") if isinstance(body, dict) else body + ) + + def __enter__(self) -> FakeResponse: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> bool: + return False + + def read(self) -> bytes: + return self._body + + def getcode(self) -> int: + return self.status + + +def _setup_journal( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, link_state: bool = True +) -> LinkState | None: + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + monkeypatch.setenv("SERVICES_PORTAL_URL", "https://portal.test") + monkeypatch.setattr(ca_module.time, "time", lambda: NOW) + if not link_state: + return None + state = LinkState.load_or_create(default_label="test home") + load_or_generate_ca(ca_dir()) + return state + + +def _response_payload( + instance_id: str, *, token: str = "reach-token" +) -> dict[str, Any]: + return { + "token": token, + "token_type": "Bearer", + "expires_at": EXPIRES_AT, + "expires_in": 86400, + "instance_id": instance_id, + } + + +def _stored_reach_state() -> object: + config = read_journal_config() + return config.get("services", {}).get("push", {}).get("reach_token") + + +def _write_reach_state(state: dict[str, Any]) -> None: + write_journal_config({"services": {"push": {"reach_token": state}}}) + + +def _capture_success_urlopen( + monkeypatch: pytest.MonkeyPatch, payload: dict[str, Any] +) -> list[Request]: + requests: list[Request] = [] + + def fake_urlopen(request: Request, timeout: float = 0) -> FakeResponse: + requests.append(request) + return FakeResponse(payload) + + monkeypatch.setattr(reach.urllib_request, "urlopen", fake_urlopen) + return requests + + +def _headers(request: Request) -> dict[str, str]: + return {key.lower(): value for key, value in request.header_items()} + + +def _body(request: Request) -> dict[str, Any]: + assert isinstance(request.data, bytes) + payload = json.loads(request.data.decode("utf-8")) + assert isinstance(payload, dict) + return payload + + +def _b64_decode(value: str) -> bytes: + padding = "=" * (-len(value) % 4) + return base64.urlsafe_b64decode(value + padding) + + +def _decode_jwt(value: str) -> tuple[dict[str, Any], dict[str, Any]]: + header_b64, payload_b64, _sig_b64 = value.split(".") + header = json.loads(_b64_decode(header_b64)) + payload = json.loads(_b64_decode(payload_b64)) + assert isinstance(header, dict) + assert isinstance(payload, dict) + return header, payload + + +def test_request_reach_token_success_body_and_assertion( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + link_state = _setup_journal(tmp_path, monkeypatch) + assert link_state is not None + ca = load_or_generate_ca(ca_dir()) + requests = _capture_success_urlopen( + monkeypatch, _response_payload(link_state.instance_id) + ) + + state = reach._request_reach_token(link_state.instance_id, ca) + + assert state == { + "token": "reach-token", + "instance_id": link_state.instance_id, + "expires_at": EXPIRES_AT, + "expires_epoch": EXPIRES_EPOCH, + } + assert len(requests) == 1 + request = requests[0] + assert request.full_url == "https://portal.test/reach/push/relay-token" + assert _headers(request)["content-type"] == "application/json" + body = _body(request) + assert body["instance_id"] == link_state.instance_id + assert body["ca_pubkey"] == ca.pubkey_spki_pem + header, payload = _decode_jwt(body["assertion"]) + assert header == {"alg": "ES256", "typ": "home-reach"} + assert payload["iss"] == f"home:{link_state.instance_id}" + assert payload["aud"] == "solstone-reach" + assert payload["scope"] == "push.relay.enroll" + assert payload["instance_id"] == link_state.instance_id + assert payload["iat"] == NOW + assert payload["exp"] - payload["iat"] == 240 + assert payload["jti"] + assert "device_fp" not in payload + + +@pytest.mark.parametrize("status", [400, 503]) +def test_request_reach_token_http_errors_return_none( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, status: int +) -> None: + link_state = _setup_journal(tmp_path, monkeypatch) + assert link_state is not None + ca = load_or_generate_ca(ca_dir()) + + def fake_urlopen(request: Request, timeout: float = 0) -> FakeResponse: + raise urllib.error.HTTPError(request.full_url, status, "nope", {}, None) + + monkeypatch.setattr(reach.urllib_request, "urlopen", fake_urlopen) + + assert reach._request_reach_token(link_state.instance_id, ca) is None + + +def test_request_reach_token_timeout_returns_none( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + link_state = _setup_journal(tmp_path, monkeypatch) + assert link_state is not None + ca = load_or_generate_ca(ca_dir()) + + def fake_urlopen(request: Request, timeout: float = 0) -> FakeResponse: + raise socket.timeout("timed out") + + monkeypatch.setattr(reach.urllib_request, "urlopen", fake_urlopen) + + assert reach._request_reach_token(link_state.instance_id, ca) is None + + +@pytest.mark.parametrize( + "body", + [ + b"not-json", + ["not", "a", "dict"], + ], +) +def test_request_reach_token_malformed_body_returns_none( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, body: bytes | list[str] +) -> None: + link_state = _setup_journal(tmp_path, monkeypatch) + assert link_state is not None + ca = load_or_generate_ca(ca_dir()) + + def fake_urlopen(request: Request, timeout: float = 0) -> FakeResponse: + return FakeResponse( + json.dumps(body).encode("utf-8") if isinstance(body, list) else body + ) + + monkeypatch.setattr(reach.urllib_request, "urlopen", fake_urlopen) + + assert reach._request_reach_token(link_state.instance_id, ca) is None + + +@pytest.mark.parametrize( + "mutate", + [ + lambda payload: {**payload, "instance_id": "other"}, + lambda payload: {**payload, "token": ""}, + lambda payload: { + key: value for key, value in payload.items() if key != "token" + }, + lambda payload: {**payload, "expires_at": ""}, + lambda payload: {**payload, "expires_at": "not-a-date"}, + lambda payload: {**payload, "expires_at": "2026-06-20T12:00:00"}, + lambda payload: {**payload, "expires_at": "2026-06-20T12:00:00-06:00"}, + ], +) +def test_request_reach_token_validation_failures_return_none( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mutate +) -> None: + link_state = _setup_journal(tmp_path, monkeypatch) + assert link_state is not None + ca = load_or_generate_ca(ca_dir()) + _capture_success_urlopen( + monkeypatch, mutate(_response_payload(link_state.instance_id)) + ) + + assert reach._request_reach_token(link_state.instance_id, ca) is None + + +def test_ensure_reach_token_success_writes_state_and_strips_stale_key( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + link_state = _setup_journal(tmp_path, monkeypatch) + assert link_state is not None + stale_key = "relay" + "_token" + write_journal_config({"services": {"push": {stale_key: "old"}}}) + _capture_success_urlopen(monkeypatch, _response_payload(link_state.instance_id)) + + assert reach.ensure_reach_token() == "reach-token" + + config = read_journal_config() + push = config["services"]["push"] + assert stale_key not in push + assert push["reach_token"] == { + "token": "reach-token", + "instance_id": link_state.instance_id, + "expires_at": EXPIRES_AT, + "expires_epoch": EXPIRES_EPOCH, + } + + +def test_ensure_reach_token_reuses_valid_state_without_post( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + link_state = _setup_journal(tmp_path, monkeypatch) + assert link_state is not None + monkeypatch.setattr(reach.time, "time", lambda: NOW) + _write_reach_state( + { + "token": "stored-token", + "instance_id": link_state.instance_id, + "expires_at": EXPIRES_AT, + "expires_epoch": NOW + 7200, + } + ) + + def fail_urlopen(request: Request, timeout: float = 0) -> FakeResponse: + raise AssertionError("urlopen should not be called") + + monkeypatch.setattr(reach.urllib_request, "urlopen", fail_urlopen) + + assert reach.ensure_reach_token() == "stored-token" + + +@pytest.mark.parametrize( + "state", + [ + None, + {"token": "old", "instance_id": "other", "expires_epoch": NOW + 7200}, + {"token": "old", "instance_id": "instance", "expires_epoch": "bad"}, + {"token": "", "instance_id": "instance", "expires_epoch": NOW + 7200}, + {"token": "old", "instance_id": "instance", "expires_epoch": NOW - 1}, + {"token": "old", "instance_id": "instance", "expires_epoch": NOW + 1800}, + ], +) +def test_ensure_reach_token_refresh_triggers( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, state: dict[str, Any] | None +) -> None: + link_state = _setup_journal(tmp_path, monkeypatch) + assert link_state is not None + monkeypatch.setattr(reach.time, "time", lambda: NOW) + if state is not None: + state = { + **state, + "instance_id": state["instance_id"].replace( + "instance", link_state.instance_id + ), + } + _write_reach_state(state) + requests = _capture_success_urlopen( + monkeypatch, _response_payload(link_state.instance_id, token="new-token") + ) + + assert reach.ensure_reach_token() == "new-token" + assert len(requests) == 1 + + +def test_ensure_reach_token_within_margin_failure_returns_existing_token( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + link_state = _setup_journal(tmp_path, monkeypatch) + assert link_state is not None + monkeypatch.setattr(reach.time, "time", lambda: NOW) + _write_reach_state( + { + "token": "stored-token", + "instance_id": link_state.instance_id, + "expires_at": EXPIRES_AT, + "expires_epoch": NOW + 1800, + } + ) + + def fail_urlopen(request: Request, timeout: float = 0) -> FakeResponse: + raise socket.timeout("timed out") + + monkeypatch.setattr(reach.urllib_request, "urlopen", fail_urlopen) + + assert reach.ensure_reach_token() == "stored-token" + + +def test_ensure_reach_token_hard_expired_failure_returns_none( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + link_state = _setup_journal(tmp_path, monkeypatch) + assert link_state is not None + monkeypatch.setattr(reach.time, "time", lambda: NOW) + _write_reach_state( + { + "token": "stored-token", + "instance_id": link_state.instance_id, + "expires_at": EXPIRES_AT, + "expires_epoch": NOW - 1, + } + ) + + def fail_urlopen(request: Request, timeout: float = 0) -> FakeResponse: + raise socket.timeout("timed out") + + monkeypatch.setattr(reach.urllib_request, "urlopen", fail_urlopen) + + assert reach.ensure_reach_token() is None + + +def test_ensure_reach_token_missing_link_state_returns_none_without_post( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _setup_journal(tmp_path, monkeypatch, link_state=False) + + def fail_urlopen(request: Request, timeout: float = 0) -> FakeResponse: + raise AssertionError("urlopen should not be called") + + monkeypatch.setattr(reach.urllib_request, "urlopen", fail_urlopen) + + assert reach.ensure_reach_token() is None + + +def test_read_reach_token_is_present_signal_without_expiry_filter( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _setup_journal(tmp_path, monkeypatch) + _write_reach_state( + { + "token": "stored-token", + "instance_id": "any", + "expires_at": EXPIRES_AT, + "expires_epoch": 1, + } + ) + + assert reach.read_reach_token() == "stored-token" + + _write_reach_state({"token": "", "expires_epoch": EXPIRES_EPOCH}) + assert reach.read_reach_token() is None + + +def test_reach_token_secrets_not_logged( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + link_state = _setup_journal(tmp_path, monkeypatch) + assert link_state is not None + captured: dict[str, Any] = {} + + def fake_urlopen(request: Request, timeout: float = 0) -> FakeResponse: + captured["body"] = _body(request) + return FakeResponse( + _response_payload(link_state.instance_id, token="secret-token") + ) + + def fail_write(config: dict[str, Any]) -> None: + raise OSError("simulated failure") + + monkeypatch.setattr(reach.urllib_request, "urlopen", fake_urlopen) + monkeypatch.setattr(reach, "write_journal_config", fail_write) + + with caplog.at_level("WARNING"): + assert reach.ensure_reach_token() == "secret-token" + + body = captured["body"] + assert "secret-token" not in caplog.text + assert body["assertion"] not in caplog.text + assert body["ca_pubkey"] not in caplog.text + + caplog.clear() + captured.clear() + ca = load_or_generate_ca(ca_dir()) + + def fail_urlopen(request: Request, timeout: float = 0) -> FakeResponse: + captured["failure_body"] = _body(request) + raise urllib.error.HTTPError(request.full_url, 503, "nope", {}, None) + + monkeypatch.setattr(reach.urllib_request, "urlopen", fail_urlopen) + + with caplog.at_level("WARNING"): + assert reach._request_reach_token(link_state.instance_id, ca) is None + + failure_body = captured["failure_body"] + assert failure_body["assertion"] not in caplog.text + assert failure_body["ca_pubkey"] not in caplog.text diff --git a/tests/test_push_routes.py b/tests/test_push_routes.py index 69b444ed8..3388d08c5 100644 --- a/tests/test_push_routes.py +++ b/tests/test_push_routes.py @@ -70,12 +70,18 @@ def _device_row() -> dict[str, object]: def test_register_push_device_happy_path(monkeypatch, tmp_path): monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + ensure_calls: list[bool] = [] + monkeypatch.setattr( + "solstone.convey.push.ensure_reach_token", + lambda: ensure_calls.append(True) or None, + ) client = _register_app().test_client() response = _post_register(client, identity=_identity("fp-1"), token="A" * 64) assert response.status_code == 200 assert response.get_json() == {"registered": True, "device_count": 1} + assert ensure_calls == [True] assert load_devices() == [ { "fingerprint": "fp-1", @@ -166,7 +172,7 @@ def test_status_shape(monkeypatch): } ], ) - monkeypatch.setattr("solstone.convey.push.push_relay_token", lambda: "tok") + monkeypatch.setattr("solstone.convey.push.read_reach_token", lambda: "tok") response = client.get("/api/push/status") @@ -189,7 +195,7 @@ def test_status_shape(monkeypatch): def test_status_relay_unavailable(monkeypatch): client = _register_app().test_client() monkeypatch.setattr("solstone.convey.push.load_devices", lambda: []) - monkeypatch.setattr("solstone.convey.push.push_relay_token", lambda: "") + monkeypatch.setattr("solstone.convey.push.read_reach_token", lambda: "") response = client.get("/api/push/status") @@ -204,7 +210,6 @@ def test_status_relay_unavailable(monkeypatch): def test_push_test_relays(monkeypatch): client = _register_app().test_client() calls: list[dict[str, str]] = [] - monkeypatch.setattr("solstone.convey.push.push_relay_token", lambda: "tok") monkeypatch.setattr("solstone.convey.push.load_devices", lambda: [_device_row()]) monkeypatch.setattr( "solstone.convey.push.dispatch_via_portal", @@ -226,19 +231,8 @@ def test_push_test_relays(monkeypatch): ] -def test_push_test_returns_503_when_relay_unavailable(monkeypatch): - client = _register_app().test_client() - monkeypatch.setattr("solstone.convey.push.push_relay_token", lambda: "") - - response = client.post("/api/push/test") - - assert response.status_code == 503 - assert response.get_json()["reason_code"] == "feature_unavailable" - - def test_push_test_returns_503_when_no_devices(monkeypatch): client = _register_app().test_client() - monkeypatch.setattr("solstone.convey.push.push_relay_token", lambda: "tok") monkeypatch.setattr("solstone.convey.push.load_devices", lambda: []) response = client.post("/api/push/test") @@ -251,7 +245,6 @@ def test_push_test_returns_503_when_no_devices(monkeypatch): def test_push_test_returns_503_when_relay_dispatch_fails(monkeypatch): client = _register_app().test_client() - monkeypatch.setattr("solstone.convey.push.push_relay_token", lambda: "tok") monkeypatch.setattr("solstone.convey.push.load_devices", lambda: [_device_row()]) monkeypatch.setattr("solstone.convey.push.dispatch_via_portal", lambda **_: None) diff --git a/tests/test_push_triggers.py b/tests/test_push_triggers.py index afaa4eeec..b3da0b027 100644 --- a/tests/test_push_triggers.py +++ b/tests/test_push_triggers.py @@ -68,7 +68,6 @@ def _fail_dedup_dispatch(**kwargs): def _install_fold_success(monkeypatch, calls: list[dict[str, str]]) -> None: - monkeypatch.setattr(triggers, "push_relay_token", lambda: "tok") monkeypatch.setattr(triggers, "load_devices", lambda: [_device_row()]) monkeypatch.setattr(triggers, "dispatch_via_portal", _fail_dispatch_via_portal) monkeypatch.setattr( @@ -87,7 +86,6 @@ def _install_chat_seed(monkeypatch) -> None: def test_handle_sol_chat_request_routes_via_portal(monkeypatch, tmp_path): monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - monkeypatch.setattr(triggers, "push_relay_token", lambda: "tok") monkeypatch.setattr(triggers, "load_devices", lambda: [_device_row()]) calls: list[dict[str, str]] = [] monkeypatch.setattr( @@ -119,14 +117,12 @@ def test_handle_sol_chat_request_routes_via_portal(monkeypatch, tmp_path): ] -def test_handle_sol_chat_request_no_token_skips_without_dispatch(monkeypatch, tmp_path): +def test_handle_sol_chat_request_dispatch_none_logs_portal_unavailable( + monkeypatch, tmp_path +): monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - monkeypatch.setattr(triggers, "push_relay_token", lambda: "") - - def fail_dispatch(**kwargs): - raise AssertionError("dispatch should not be called") - - monkeypatch.setattr(triggers, "dispatch_via_portal", fail_dispatch) + monkeypatch.setattr(triggers, "load_devices", lambda: [_device_row()]) + monkeypatch.setattr(triggers, "dispatch_via_portal", lambda **kwargs: None) triggers.handle_sol_chat_request( { @@ -145,7 +141,7 @@ def test_handle_sol_chat_request_no_token_skips_without_dispatch(monkeypatch, tm "dedupe_key": "req-1", "category": "notice", "outcome": "skipped", - "reason": "no_relay_token", + "reason": "portal_unavailable", } ] @@ -154,7 +150,6 @@ def test_handle_sol_chat_request_no_devices_skips_without_dispatch( monkeypatch, tmp_path ): monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - monkeypatch.setattr(triggers, "push_relay_token", lambda: "tok") monkeypatch.setattr(triggers, "load_devices", lambda: []) def fail_dispatch(**kwargs): @@ -186,7 +181,6 @@ def test_handle_sol_chat_request_no_devices_skips_without_dispatch( def test_handle_sol_chat_request_portal_unavailable_logs_skip(monkeypatch, tmp_path): monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - monkeypatch.setattr(triggers, "push_relay_token", lambda: "tok") monkeypatch.setattr(triggers, "load_devices", lambda: [_device_row()]) monkeypatch.setattr(triggers, "dispatch_via_portal", lambda **kwargs: None) @@ -215,7 +209,6 @@ def test_handle_sol_chat_request_portal_unavailable_logs_skip(monkeypatch, tmp_p @pytest.mark.parametrize("event", [KIND_OWNER_CHAT_OPEN, KIND_OWNER_CHAT_DISMISSED]) def test_handle_chat_lifecycle_routes_via_portal(monkeypatch, tmp_path, event): monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - monkeypatch.setattr(triggers, "push_relay_token", lambda: "tok") monkeypatch.setattr(triggers, "load_devices", lambda: [_device_row()]) calls: list[dict[str, str]] = [] monkeypatch.setattr( @@ -241,14 +234,12 @@ def test_handle_chat_lifecycle_routes_via_portal(monkeypatch, tmp_path, event): ] -def test_handle_chat_lifecycle_no_token_skips_without_dispatch(monkeypatch, tmp_path): +def test_handle_chat_lifecycle_dispatch_none_logs_portal_unavailable( + monkeypatch, tmp_path +): monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - monkeypatch.setattr(triggers, "push_relay_token", lambda: "") - - def fail_dispatch(**kwargs): - raise AssertionError("dispatch should not be called") - - monkeypatch.setattr(triggers, "dispatch_dedup_via_portal", fail_dispatch) + monkeypatch.setattr(triggers, "load_devices", lambda: [_device_row()]) + monkeypatch.setattr(triggers, "dispatch_dedup_via_portal", lambda **kwargs: None) triggers.handle_chat_lifecycle( {"tract": "chat", "event": KIND_OWNER_CHAT_OPEN, "request_id": "req-1"} @@ -261,14 +252,13 @@ def test_handle_chat_lifecycle_no_token_skips_without_dispatch(monkeypatch, tmp_ "dedupe_key": "req-1", "category": KIND_OWNER_CHAT_OPEN, "outcome": "skipped", - "reason": "no_relay_token", + "reason": "portal_unavailable", } ] def test_handle_chat_lifecycle_portal_unavailable_logs_skip(monkeypatch, tmp_path): monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - monkeypatch.setattr(triggers, "push_relay_token", lambda: "tok") monkeypatch.setattr(triggers, "load_devices", lambda: [_device_row()]) monkeypatch.setattr(triggers, "dispatch_dedup_via_portal", lambda **kwargs: None) @@ -387,7 +377,6 @@ def test_handle_chat_fold_recovery_shape_stays_content_free(monkeypatch, tmp_pat ) def test_handle_chat_fold_noop_shapes_do_not_dispatch(monkeypatch, tmp_path, message): monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - monkeypatch.setattr(triggers, "push_relay_token", lambda: "tok") monkeypatch.setattr(triggers, "load_devices", lambda: [_device_row()]) monkeypatch.setattr(triggers, "dispatch_via_portal", _fail_dispatch_via_portal) monkeypatch.setattr(triggers, "dispatch_dedup_via_portal", _fail_dedup_dispatch) @@ -406,7 +395,6 @@ def test_handle_chat_fold_suppresses_when_owner_viewing(monkeypatch, tmp_path): surface="convey", ts=_FOLD_TS_MS - 5 * 60 * 1000, ) - monkeypatch.setattr(triggers, "push_relay_token", lambda: "tok") monkeypatch.setattr(triggers, "load_devices", lambda: [_device_row()]) monkeypatch.setattr(triggers, "dispatch_via_portal", _fail_dispatch_via_portal) monkeypatch.setattr(triggers, "dispatch_dedup_via_portal", _fail_dedup_dispatch) @@ -484,11 +472,11 @@ def test_handle_chat_fold_uses_origin_logical_id_for_dedup(monkeypatch, tmp_path ] -def test_handle_chat_fold_no_token_skips_without_dispatch(monkeypatch, tmp_path): +def test_handle_chat_fold_dispatch_none_logs_portal_unavailable(monkeypatch, tmp_path): monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - monkeypatch.setattr(triggers, "push_relay_token", lambda: "") + monkeypatch.setattr(triggers, "load_devices", lambda: [_device_row()]) monkeypatch.setattr(triggers, "dispatch_via_portal", _fail_dispatch_via_portal) - monkeypatch.setattr(triggers, "dispatch_dedup_via_portal", _fail_dedup_dispatch) + monkeypatch.setattr(triggers, "dispatch_dedup_via_portal", lambda **kwargs: None) triggers.handle_chat_fold(_fold_message(route_id="dispatch-1")) @@ -499,14 +487,13 @@ def test_handle_chat_fold_no_token_skips_without_dispatch(monkeypatch, tmp_path) "dedupe_key": "dispatch-1", "category": triggers.FOLD_PUSH_ACTION, "outcome": "skipped", - "reason": "no_relay_token", + "reason": "portal_unavailable", } ] def test_handle_chat_fold_no_devices_skips_without_dispatch(monkeypatch, tmp_path): monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - monkeypatch.setattr(triggers, "push_relay_token", lambda: "tok") monkeypatch.setattr(triggers, "load_devices", lambda: []) monkeypatch.setattr(triggers, "dispatch_via_portal", _fail_dispatch_via_portal) monkeypatch.setattr(triggers, "dispatch_dedup_via_portal", _fail_dedup_dispatch) @@ -527,7 +514,6 @@ def test_handle_chat_fold_no_devices_skips_without_dispatch(monkeypatch, tmp_pat def test_handle_chat_fold_portal_unavailable_logs_skip(monkeypatch, tmp_path): monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - monkeypatch.setattr(triggers, "push_relay_token", lambda: "tok") monkeypatch.setattr(triggers, "load_devices", lambda: [_device_row()]) monkeypatch.setattr(triggers, "dispatch_via_portal", _fail_dispatch_via_portal) monkeypatch.setattr(triggers, "dispatch_dedup_via_portal", lambda **kwargs: None) @@ -556,7 +542,6 @@ def test_handle_chat_fold_portal_unavailable_logs_skip(monkeypatch, tmp_path): ) def test_non_chat_or_wrong_event_messages_are_noops(monkeypatch, tmp_path, message): monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - monkeypatch.setattr(triggers, "push_relay_token", lambda: "tok") monkeypatch.setattr(triggers, "dispatch_via_portal", _fail_dispatch_via_portal) monkeypatch.setattr(triggers, "dispatch_dedup_via_portal", _fail_dedup_dispatch)