Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,417 @@
|
||||
"""Tests for the Photon auth module (device login + dashboard API)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import threading
|
||||
import time
|
||||
from base64 import b64encode
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.platforms.photon import auth as photon_auth
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fake httpx — we don't want to hit the real Photon API in unit tests.
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
status: int = 200,
|
||||
json_body: Any = None,
|
||||
headers: Dict[str, str] | None = None,
|
||||
text: str = "",
|
||||
) -> None:
|
||||
self.status_code = status
|
||||
self._json = json_body if json_body is not None else {}
|
||||
self.headers = headers or {}
|
||||
self.text = text
|
||||
|
||||
def json(self) -> Any:
|
||||
return self._json
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
if self.status_code >= 400:
|
||||
raise RuntimeError(f"HTTP {self.status_code}")
|
||||
|
||||
|
||||
_PHOTON_ENV = (
|
||||
"PHOTON_PROJECT_ID",
|
||||
"PHOTON_PROJECT_SECRET",
|
||||
"PHOTON_DASHBOARD_PROJECT_ID",
|
||||
"PHOTON_SPECTRUM_HOST",
|
||||
"PHOTON_ALLOWED_USERS",
|
||||
"PHOTON_HOME_CHANNEL",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_hermes_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
home = tmp_path / "hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
for key in _PHOTON_ENV:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
yield home
|
||||
# save_env_value() mutates os.environ directly, so scrub any leakage.
|
||||
for key in _PHOTON_ENV:
|
||||
os.environ.pop(key, None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credential storage
|
||||
|
||||
def test_store_and_load_photon_token(tmp_hermes_home: Path) -> None:
|
||||
photon_auth.store_photon_token("abc123def456")
|
||||
assert photon_auth.load_photon_token() == "abc123def456"
|
||||
|
||||
auth_json = json.loads((tmp_hermes_home / "auth.json").read_text())
|
||||
assert auth_json["credential_pool"]["photon"][0]["access_token"] == "abc123def456"
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "posix", reason="POSIX mode bits only")
|
||||
def test_save_auth_never_world_readable(tmp_hermes_home: Path) -> None:
|
||||
"""auth.json must be created 0o600 — no window at process umask."""
|
||||
photon_auth.store_photon_token("secret-token")
|
||||
mode = (tmp_hermes_home / "auth.json").stat().st_mode & 0o777
|
||||
assert mode == 0o600
|
||||
|
||||
|
||||
def test_store_project_credentials_round_trip(
|
||||
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Don't touch .env / os.environ here — exercise the auth.json path.
|
||||
monkeypatch.setattr(photon_auth, "_persist_runtime_env", lambda *a, **k: None)
|
||||
photon_auth.store_project_credentials(
|
||||
spectrum_project_id="sp-123",
|
||||
project_secret="secret-key",
|
||||
dashboard_project_id="dash-456",
|
||||
name="Hermes Agent",
|
||||
)
|
||||
for key in _PHOTON_ENV:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
sid, secret = photon_auth.load_project_credentials()
|
||||
assert sid == "sp-123"
|
||||
assert secret == "secret-key"
|
||||
# Post-unification the management id resolves to the Spectrum id, not the
|
||||
# stored dashboard id — so a pre-backfill diverged install (whose old
|
||||
# dashboard id was rewritten and now 404s) still reaches the live row.
|
||||
assert photon_auth.load_dashboard_project_id() == "sp-123"
|
||||
|
||||
|
||||
def test_load_user_numbers_falls_back_to_home_channel(
|
||||
tmp_hermes_home: Path,
|
||||
) -> None:
|
||||
from hermes_cli.config import save_env_value
|
||||
|
||||
save_env_value("PHOTON_HOME_CHANNEL", "+15551234567")
|
||||
|
||||
phone, assigned = photon_auth.load_user_numbers()
|
||||
assert phone == "+15551234567"
|
||||
assert assigned is None
|
||||
|
||||
|
||||
def test_refresh_user_numbers_reads_existing_assignment(
|
||||
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
photon_auth.store_user_numbers(phone_number="+15551234567")
|
||||
|
||||
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
assert kwargs.get("headers", {}).get("Authorization") == (
|
||||
"Basic " + b64encode(b"sp:secret").decode("ascii")
|
||||
)
|
||||
assert url.endswith("/projects/sp/users/")
|
||||
return _FakeResponse(json_body={"succeed": True, "data": {"users": [{
|
||||
"id": "user-uuid",
|
||||
"phoneNumber": "+1 (555) 123-4567",
|
||||
"assignedPhoneNumber": "+16282679185",
|
||||
}]}})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
|
||||
phone, assigned = photon_auth.refresh_user_numbers("sp", "secret")
|
||||
assert phone == "+15551234567"
|
||||
assert assigned == "+16282679185"
|
||||
assert photon_auth.load_user_numbers() == ("+15551234567", "+16282679185")
|
||||
|
||||
|
||||
def test_load_project_credentials_env_override(
|
||||
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(photon_auth, "_persist_runtime_env", lambda *a, **k: None)
|
||||
photon_auth.store_project_credentials(
|
||||
spectrum_project_id="from-file", project_secret="secret-file",
|
||||
)
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "from-env")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "secret-env")
|
||||
sid, secret = photon_auth.load_project_credentials()
|
||||
assert sid == "from-env"
|
||||
assert secret == "secret-env"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-process auth.json lock (issue: photon wrote auth.json without the
|
||||
# cross-process lock hermes_cli/auth.py's ~15 other writers all use, so a
|
||||
# concurrent refresh from elsewhere could silently lose photon's update or
|
||||
# vice versa).
|
||||
|
||||
def _hold_auth_lock_then_release(hold_event: threading.Event, release_event: threading.Event) -> None:
|
||||
from hermes_cli.auth import _auth_store_lock
|
||||
|
||||
with _auth_store_lock():
|
||||
hold_event.set()
|
||||
release_event.wait(timeout=5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device login flow
|
||||
|
||||
def test_request_device_code_uses_photon_cli(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: Dict[str, Any] = {}
|
||||
|
||||
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
captured["url"] = url
|
||||
captured["body"] = kwargs.get("json")
|
||||
return _FakeResponse(json_body={
|
||||
"device_code": "dev-code-xyz",
|
||||
"user_code": "ABCD-1234",
|
||||
"verification_uri": "https://app.photon.codes/device",
|
||||
"verification_uri_complete": "https://app.photon.codes/device?code=ABCD-1234",
|
||||
"expires_in": 600,
|
||||
"interval": 5,
|
||||
})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
|
||||
code = photon_auth.request_device_code()
|
||||
assert code.device_code == "dev-code-xyz"
|
||||
assert code.user_code == "ABCD-1234"
|
||||
assert "/api/auth/device/code" in captured["url"]
|
||||
# Hosted Photon allowlists registered device clients — an unregistered
|
||||
# client_id is rejected with 400 invalid_client. We use Photon's published
|
||||
# CLI device client and send the standard scope.
|
||||
assert captured["body"]["client_id"] == "photon-cli"
|
||||
assert captured["body"]["scope"] == "openid profile email"
|
||||
|
||||
|
||||
def _device_code() -> "photon_auth.DeviceCode":
|
||||
return photon_auth.DeviceCode(
|
||||
device_code="d", user_code="u",
|
||||
verification_uri="https://x", verification_uri_complete=None,
|
||||
expires_in=10, interval=0,
|
||||
)
|
||||
|
||||
|
||||
def test_poll_for_token_body_access_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
return _FakeResponse(status=200, json_body={"access_token": "tok-body"})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
assert photon_auth.poll_for_token(_device_code(), interval=0, timeout=2) == "tok-body"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Projects
|
||||
|
||||
def test_list_projects_unwraps_list(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
return _FakeResponse(json_body=[{"id": "p1", "name": "Hermes Agent"}])
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
projects = photon_auth.list_projects("tok")
|
||||
assert projects[0]["id"] == "p1"
|
||||
|
||||
|
||||
def test_find_project_by_name_case_insensitive(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
return _FakeResponse(json_body={"data": [
|
||||
{"id": "p1", "name": "Other"},
|
||||
{"id": "p2", "name": "hermes agent"},
|
||||
]})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
proj = photon_auth.find_project_by_name("tok", "Hermes Agent")
|
||||
assert proj is not None and proj["id"] == "p2"
|
||||
|
||||
|
||||
def test_create_project_omits_spectrum_flag(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: Dict[str, Any] = {}
|
||||
|
||||
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
captured["url"] = url
|
||||
captured["body"] = kwargs.get("json")
|
||||
captured["headers"] = kwargs.get("headers")
|
||||
return _FakeResponse(json_body={"success": True, "id": "new-proj"})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
data = photon_auth.create_project("tok", name="Hermes Agent")
|
||||
assert data["id"] == "new-proj"
|
||||
# Spectrum is always provisioned at create-time; the field was dropped
|
||||
# from the API schema, so we must not send it.
|
||||
assert "spectrum" not in captured["body"]
|
||||
assert captured["body"]["name"] == "Hermes Agent"
|
||||
assert captured["headers"]["Authorization"] == "Bearer tok"
|
||||
assert captured["url"].endswith("/api/projects")
|
||||
|
||||
|
||||
def test_regenerate_project_secret(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
assert url.endswith("/regenerate-secret")
|
||||
return _FakeResponse(json_body={"success": True, "projectSecret": "rotated"})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
assert photon_auth.regenerate_project_secret("tok", "p") == "rotated"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Users
|
||||
|
||||
|
||||
def test_register_user_if_absent_dedup(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
posted = {"n": 0}
|
||||
|
||||
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
return _FakeResponse(json_body={"succeed": True, "data": {"users": [{
|
||||
"id": "u1",
|
||||
"phoneNumber": "+1 (555) 123-4567",
|
||||
"assignedPhoneNumber": "+16282679185",
|
||||
}]}})
|
||||
|
||||
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
posted["n"] += 1
|
||||
return _FakeResponse(json_body={"success": True, "user": {}})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
# Same number, different formatting — should match and NOT create.
|
||||
user, created = photon_auth.register_user_if_absent(
|
||||
"proj", "secret", phone_number="+15551234567",
|
||||
)
|
||||
assert created is False
|
||||
assert user["id"] == "u1"
|
||||
assert posted["n"] == 0
|
||||
# The reused user carries the assigned iMessage line ("TEXTS ON").
|
||||
assert photon_auth.user_assigned_line(user) == "+16282679185"
|
||||
|
||||
|
||||
def test_user_assigned_line() -> None:
|
||||
assert (
|
||||
photon_auth.user_assigned_line({"assignedPhoneNumber": "+16282679185"})
|
||||
== "+16282679185"
|
||||
)
|
||||
# Own number present but no assignment yet (e.g. freshly created user).
|
||||
assert photon_auth.user_assigned_line({"phoneNumber": "+15551234567"}) is None
|
||||
assert photon_auth.user_assigned_line({"assignedPhoneNumber": ""}) is None
|
||||
assert photon_auth.user_assigned_line({}) is None
|
||||
assert photon_auth.user_assigned_line(None) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lines (assigned number)
|
||||
|
||||
def test_get_imessage_line_returns_existing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
return _FakeResponse(json_body=[
|
||||
{"id": "l1", "platform": "imessage", "phoneNumber": "+15559999999", "status": "active"},
|
||||
])
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
line = photon_auth.get_imessage_line("tok", "proj")
|
||||
assert line is not None and line["phoneNumber"] == "+15559999999"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credential summary (no secret leakage)
|
||||
|
||||
def test_credential_summary_no_secret_leak(
|
||||
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(photon_auth, "_persist_runtime_env", lambda *a, **k: None)
|
||||
photon_auth.store_photon_token("token-aaaaaaaaaaaaaaaa")
|
||||
photon_auth.store_project_credentials(
|
||||
spectrum_project_id="sp-uuid",
|
||||
project_secret="secret-bbbbbbbbbbb",
|
||||
dashboard_project_id="dash-uuid",
|
||||
)
|
||||
summary = photon_auth.credential_summary()
|
||||
blob = "\n".join(summary.values())
|
||||
assert "token-aaaa" not in blob
|
||||
assert "secret-bbbb" not in blob
|
||||
assert summary["device_token"].startswith("✓")
|
||||
assert summary["project_key"].startswith("✓")
|
||||
# Unified id: dashboard id == Spectrum id, surfaced as one project id.
|
||||
assert summary["project_id"] == "sp-uuid"
|
||||
assert summary["phone_number"].startswith("✗ missing")
|
||||
assert summary["assigned_phone_number"].startswith("✗ missing")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device-token candidate extraction + dashboard validation.
|
||||
|
||||
def test_device_response_candidates_covers_known_shapes() -> None:
|
||||
candidates = photon_auth._device_response_token_candidates(
|
||||
{
|
||||
"access_token": "tok-snake",
|
||||
"accessToken": "tok-camel",
|
||||
"data": {"access_token": "tok-data"},
|
||||
},
|
||||
headers={"set-auth-token": "Bearer tok-header"},
|
||||
)
|
||||
by_source = {c.source: c.token for c in candidates}
|
||||
assert by_source["access_token"] == "tok-snake"
|
||||
assert by_source["accessToken"] == "tok-camel"
|
||||
assert by_source["data.access_token"] == "tok-data"
|
||||
# "Bearer " prefix is stripped from the header value.
|
||||
assert by_source["set-auth-token"] == "tok-header"
|
||||
|
||||
|
||||
def test_validate_photon_token_rejects_unrecognized_session(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def fake_get(url: str, *, headers: Dict[str, str], timeout: float) -> _FakeResponse:
|
||||
if url.endswith("/api/auth/get-session"):
|
||||
return _FakeResponse(json_body={}) # no "user" key
|
||||
return _FakeResponse(json_body=[])
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
with pytest.raises(photon_auth.PhotonDashboardAuthError):
|
||||
photon_auth.validate_photon_token("some-token")
|
||||
|
||||
|
||||
def test_login_device_flow_validates_before_persisting(
|
||||
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def fake_post(url: str, *, json: Dict[str, Any], timeout: float) -> _FakeResponse:
|
||||
if url.endswith("/api/auth/device/code"):
|
||||
return _FakeResponse(json_body={
|
||||
"device_code": "dev", "user_code": "AAAA",
|
||||
"verification_uri": "https://app.photon.codes/device",
|
||||
"verification_uri_complete": None,
|
||||
"expires_in": 600, "interval": 0,
|
||||
})
|
||||
# device/token approval
|
||||
return _FakeResponse(json_body={"access_token": "good-token"})
|
||||
|
||||
def fake_get(url: str, *, headers: Dict[str, str], timeout: float) -> _FakeResponse:
|
||||
if url.endswith("/api/auth/get-session"):
|
||||
return _FakeResponse(json_body={"user": {"id": "u1"}})
|
||||
return _FakeResponse(json_body=[]) # projects OK
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
# interval=0 falls back to DEFAULT_POLL_INTERVAL inside the poll loop
|
||||
# ("sleep first, then poll") — stub the sleep so the test doesn't idle 5s.
|
||||
monkeypatch.setattr(photon_auth.time, "sleep", lambda _s: None)
|
||||
|
||||
token = photon_auth.login_device_flow(open_browser=False)
|
||||
assert token == "good-token"
|
||||
assert photon_auth.load_photon_token() == "good-token"
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Tests for check_requirements() diagnostic logging (fix) and remaining risks.
|
||||
|
||||
Fixed in this file (tests PASS with fix, FAIL without):
|
||||
- check_requirements() now emits a specific logger.warning for each False
|
||||
condition so gateway logs pinpoint the exact failure reason.
|
||||
|
||||
Remaining risks documented here (still open — separate issues):
|
||||
Risk 2 – node_modules dir exists but EMPTY (partial/aborted npm install)
|
||||
→ check_requirements() returns True (false positive)
|
||||
Risk 3 – _install_sidecar() subprocess.run calls carry no capture_output /
|
||||
stdout / stderr — npm error output is unrecoverable after the run
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.platforms.photon import adapter as adapter_mod
|
||||
from plugins.platforms.photon import cli as cli_mod
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers / shared marks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_NODE_ON_PATH = shutil.which("node") is not None
|
||||
|
||||
_requires_node = pytest.mark.skipif(
|
||||
not _NODE_ON_PATH,
|
||||
reason="requires node on PATH to isolate the node_modules check",
|
||||
)
|
||||
|
||||
_requires_node_for_false_positive = pytest.mark.skipif(
|
||||
not _NODE_ON_PATH,
|
||||
reason="requires node on PATH so the false-positive path is reachable",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fix verification — each False branch now emits a specific warning
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_fix_logs_warning_when_httpx_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""When httpx is not installed, check_requirements() must log a warning
|
||||
that names the missing package so the operator knows what to install."""
|
||||
monkeypatch.setattr(adapter_mod, "HTTPX_AVAILABLE", False)
|
||||
monkeypatch.setattr(adapter_mod, "_SIDECAR_DIR", tmp_path)
|
||||
(tmp_path / "node_modules").mkdir()
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="plugins.platforms.photon.adapter"):
|
||||
result = adapter_mod.check_requirements()
|
||||
|
||||
assert result is False
|
||||
messages = [r.message for r in caplog.records]
|
||||
assert any("httpx" in m for m in messages), (
|
||||
f"Expected a warning mentioning 'httpx', got: {messages}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Risk 2 (open) — empty node_modules directory is a false positive
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@_requires_node_for_false_positive
|
||||
def test_risk2_fix_empty_node_modules_no_longer_passes_guard(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""npm may create node_modules/ before aborting (network timeout, ENOSPC,
|
||||
EACCES). Previously an empty directory passed the only filesystem guard in
|
||||
check_requirements() — returning True with a broken sidecar installation.
|
||||
Fixed: check_requirements() now verifies node_modules/spectrum-ts exists,
|
||||
so a partial/empty node_modules/ correctly returns False."""
|
||||
monkeypatch.setattr(adapter_mod, "HTTPX_AVAILABLE", True)
|
||||
monkeypatch.setattr(adapter_mod, "_SIDECAR_DIR", tmp_path)
|
||||
monkeypatch.setattr(adapter_mod, "_NPM_ERROR_LOG", tmp_path / ".photon-npm-error.log")
|
||||
# NS-606: disable the connect-time self-heal branch so the guard itself
|
||||
# (empty node_modules must not read as installed) is what's under test.
|
||||
monkeypatch.setattr(adapter_mod, "_dir_writable", lambda _p: False)
|
||||
(tmp_path / "node_modules").mkdir() # empty — spectrum-ts absent
|
||||
|
||||
# Fix verified: False instead of the old false-positive True.
|
||||
assert adapter_mod.check_requirements() is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Risk 3 fix — npm stderr is captured, persisted, and surfaced by check_requirements
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared predicate — status / _start_sidecar / check_requirements must agree
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cli_status_shares_adapter_sidecar_deps_check(tmp_path: Path) -> None:
|
||||
"""`hermes photon status` must use the exact same spectrum-ts check as
|
||||
check_requirements() / _start_sidecar() — not a separate node_modules-only
|
||||
existence check that would disagree on a partial/empty install."""
|
||||
assert cli_mod.sidecar_deps_installed is adapter_mod.sidecar_deps_installed
|
||||
|
||||
|
||||
def test_sidecar_deps_installed_false_on_empty_node_modules(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
monkeypatch.setattr(adapter_mod, "_SIDECAR_DIR", tmp_path)
|
||||
(tmp_path / "node_modules").mkdir() # empty — spectrum-ts absent
|
||||
assert adapter_mod.sidecar_deps_installed() is False
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Photon's fatal-error notification must not be cancelled by its own teardown.
|
||||
|
||||
`_monitor_sidecar_health` and `_supervise_sidecar` run as long-lived tasks on
|
||||
the adapter. When one of them detects a fatal condition, the gateway's handler
|
||||
tears the adapter down, and `disconnect()` cancels `_sidecar_health_task` and
|
||||
awaits it. If the notification is awaited inline on the health task's own call
|
||||
stack, `disconnect()` ends up cancelling its own ultimate caller: the
|
||||
`task is not asyncio.current_task()` guard in `disconnect()` compares against
|
||||
the wrapper task the gateway created around `disconnect()`, not the health task
|
||||
several plain-await frames further up, so the guard passes and the cancel lands.
|
||||
|
||||
`CancelledError` stopped subclassing `Exception` in Python 3.8, so the
|
||||
`except Exception` that used to wrap the inline notify call never saw it. The
|
||||
health task died silently mid-handoff -- no log line, no retry -- and the
|
||||
platform stayed stranded until someone restarted the gateway by hand.
|
||||
|
||||
PR #69112 hardened the shared dispatch path in `gateway/run.py` against a
|
||||
cancelled *caller*. These tests cover the Photon-specific self-cancellation one
|
||||
layer down, which that PR's scope could not reach.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from plugins.platforms.photon.adapter import PhotonAdapter
|
||||
|
||||
|
||||
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
|
||||
return PhotonAdapter(PlatformConfig(enabled=True, token="", extra={}))
|
||||
|
||||
|
||||
class TestFatalNotifyIsDetached:
|
||||
"""The notification must outlive cancellation of the task that raised it."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_task_cancellation_does_not_kill_notification(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A fatal handler that cancels the health task (exactly what
|
||||
``disconnect()`` does) must still see the notification delivered."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
adapter._inbound_running = True
|
||||
adapter._sidecar_health_interval = 0
|
||||
delivered = asyncio.Event()
|
||||
|
||||
async def fake_notify() -> None:
|
||||
# Mirror the real handler: tear the adapter down, cancelling the
|
||||
# health task, then finish the handoff.
|
||||
await adapter.disconnect()
|
||||
delivered.set()
|
||||
|
||||
monkeypatch.setattr(adapter, "_notify_fatal_error", fake_notify)
|
||||
monkeypatch.setattr(adapter, "_stop_sidecar", lambda: _noop())
|
||||
|
||||
async def degraded(_path: str, _payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {"stream": {"ok": False, "state": "degraded", "degradedForMs": 4000,
|
||||
"lastIssue": "stream persistently failing"}}
|
||||
|
||||
monkeypatch.setattr(adapter, "_sidecar_call", degraded)
|
||||
|
||||
health = asyncio.create_task(adapter._monitor_sidecar_health())
|
||||
adapter._sidecar_health_task = health
|
||||
|
||||
await asyncio.wait_for(delivered.wait(), timeout=5.0)
|
||||
assert adapter.has_fatal_error
|
||||
assert adapter.fatal_error_code == "UPSTREAM_STREAM_DEGRADED"
|
||||
assert adapter.fatal_error_retryable
|
||||
|
||||
# With the dispatch detached, the health task reaches its own `break`
|
||||
# and returns cleanly instead of being cancelled out from under the
|
||||
# handoff. Either way it must not die with an unhandled exception --
|
||||
# that was the silent failure that stranded the platform.
|
||||
await asyncio.wait_for(asyncio.shield(health), timeout=2.0)
|
||||
assert health.done()
|
||||
if not health.cancelled():
|
||||
assert health.exception() is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_does_not_await_on_caller_stack(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""``_dispatch_fatal_notification`` must return without awaiting, so a
|
||||
cancel aimed at the calling task cannot reach the handoff."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
started = asyncio.Event()
|
||||
finished = asyncio.Event()
|
||||
|
||||
async def slow_notify() -> None:
|
||||
started.set()
|
||||
await asyncio.sleep(0.05)
|
||||
finished.set()
|
||||
|
||||
monkeypatch.setattr(adapter, "_notify_fatal_error", slow_notify)
|
||||
|
||||
async def caller() -> None:
|
||||
adapter._dispatch_fatal_notification() # must not block
|
||||
|
||||
task = asyncio.create_task(caller())
|
||||
await task # returns immediately even though notify sleeps
|
||||
await asyncio.wait_for(started.wait(), timeout=2.0)
|
||||
|
||||
task.cancel() # cancelling the caller must not touch the notification
|
||||
await asyncio.wait_for(finished.wait(), timeout=2.0)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notification_failure_is_logged_not_raised(
|
||||
self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A failing notification must warn rather than surface as an
|
||||
unretrieved task exception."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
|
||||
async def boom() -> None:
|
||||
raise RuntimeError("gateway unreachable")
|
||||
|
||||
monkeypatch.setattr(adapter, "_notify_fatal_error", boom)
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
await adapter._notify_fatal_error_logged()
|
||||
|
||||
assert "fatal-error notification failed" in caplog.text
|
||||
|
||||
|
||||
class TestBothCallSitesDetached:
|
||||
"""Neither fatal path may await the notification inline."""
|
||||
|
||||
def test_no_inline_notify_awaits_remain(self) -> None:
|
||||
"""Guard against a future edit reintroducing the inline await."""
|
||||
import inspect
|
||||
|
||||
from plugins.platforms.photon import adapter as photon_adapter
|
||||
|
||||
for name in ("_monitor_sidecar_health", "_supervise_sidecar"):
|
||||
src = inspect.getsource(getattr(photon_adapter.PhotonAdapter, name))
|
||||
assert "await self._notify_fatal_error()" not in src, (
|
||||
f"{name} awaits _notify_fatal_error inline; use "
|
||||
f"_dispatch_fatal_notification() so disconnect() cannot cancel "
|
||||
f"its own caller"
|
||||
)
|
||||
assert "_dispatch_fatal_notification()" in src
|
||||
|
||||
|
||||
async def _noop() -> None:
|
||||
return None
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Inbound dispatch + dedup tests for PhotonAdapter.
|
||||
|
||||
These bypass the loopback HTTP stream — they call ``_dispatch_inbound`` /
|
||||
``_on_inbound_line`` / ``_is_duplicate`` directly, exercising the
|
||||
sidecar-event parsing without spawning the Node sidecar or binding ports.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent, MessageType
|
||||
from plugins.platforms.photon.adapter import PhotonAdapter
|
||||
|
||||
|
||||
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
return PhotonAdapter(cfg)
|
||||
|
||||
|
||||
def _capture(adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch) -> List[MessageEvent]:
|
||||
captured: List[MessageEvent] = []
|
||||
|
||||
async def fake_handle(event: MessageEvent) -> None:
|
||||
captured.append(event)
|
||||
|
||||
monkeypatch.setattr(adapter, "handle_message", fake_handle)
|
||||
return captured
|
||||
|
||||
|
||||
def _dm_event(text: str, msg_id: str = "spc-msg-abc") -> Dict[str, Any]:
|
||||
return {
|
||||
"messageId": msg_id,
|
||||
"platform": "iMessage",
|
||||
"space": {"id": "+15551234567", "type": "dm", "phone": "+15551234567"},
|
||||
"sender": {"id": "+15551234567"},
|
||||
"content": {"type": "text", "text": text},
|
||||
"timestamp": "2026-05-14T19:06:32.000Z",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_text_dm(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
|
||||
await adapter._dispatch_inbound(_dm_event("hello world"))
|
||||
|
||||
assert len(captured) == 1
|
||||
event = captured[0]
|
||||
assert event.text == "hello world"
|
||||
assert event.message_type == MessageType.TEXT
|
||||
assert event.message_id == "spc-msg-abc"
|
||||
src = event.source
|
||||
assert src is not None
|
||||
assert src.platform == Platform("photon")
|
||||
assert src.chat_id == "+15551234567"
|
||||
assert src.chat_type == "dm"
|
||||
assert src.user_id == "+15551234567"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_read_receipt_does_not_wake_agent(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
receipt = _dm_event("", msg_id="spc-read-1")
|
||||
receipt["content"] = {
|
||||
"type": "read",
|
||||
"targetMessageId": "bot-msg-1",
|
||||
"targetDirection": "outbound",
|
||||
}
|
||||
|
||||
await adapter._dispatch_inbound(receipt)
|
||||
|
||||
assert captured == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_read_receipt_alias_does_not_wake_agent(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Some spectrum-ts streams label receipts ``read_receipt`` — same drop."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
receipt = _dm_event("", msg_id="spc-read-2")
|
||||
receipt["content"] = {
|
||||
"type": "read_receipt",
|
||||
"targetMessageId": "bot-msg-2",
|
||||
"targetDirection": "outbound",
|
||||
}
|
||||
|
||||
await adapter._dispatch_inbound(receipt)
|
||||
|
||||
assert captured == []
|
||||
|
||||
|
||||
# A real 1x1 transparent PNG (passes base.py's _looks_like_image magic check).
|
||||
_PNG_1X1_B64 = (
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhf"
|
||||
"DwAChwGA60e6kgAAAABJRU5ErkJggg=="
|
||||
)
|
||||
|
||||
|
||||
def _attachment_event(
|
||||
content: Dict[str, Any], msg_id: str = "spc-msg-att"
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"messageId": msg_id,
|
||||
"space": {"id": "+15551234567", "type": "dm", "phone": "+15551234567"},
|
||||
"sender": {"id": "+15551234567"},
|
||||
"content": {"type": "attachment", **content},
|
||||
"timestamp": "2026-05-14T19:06:32.000Z",
|
||||
}
|
||||
|
||||
|
||||
def _voice_event(
|
||||
content: Dict[str, Any], msg_id: str = "spc-msg-voice"
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"messageId": msg_id,
|
||||
"space": {"id": "+15551234567", "type": "dm", "phone": "+15551234567"},
|
||||
"sender": {"id": "+15551234567"},
|
||||
"content": {"type": "voice", **content},
|
||||
"timestamp": "2026-05-14T19:06:32.000Z",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_inbound_line_dispatches_and_dedups(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
|
||||
line = json.dumps(_dm_event("ping", msg_id="dup-1"))
|
||||
await adapter._on_inbound_line(line)
|
||||
await adapter._on_inbound_line(line) # same messageId -> deduped
|
||||
|
||||
assert len(captured) == 1
|
||||
assert captured[0].text == "ping"
|
||||
|
||||
|
||||
def test_is_duplicate_window(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
assert adapter._is_duplicate("id-1") is False
|
||||
assert adapter._is_duplicate("id-1") is True
|
||||
assert adapter._is_duplicate("id-2") is False
|
||||
assert adapter._is_duplicate("id-1") is True # still dup
|
||||
|
||||
|
||||
def test_check_requirements_without_node(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# If no node binary on PATH the adapter should refuse to start.
|
||||
from plugins.platforms.photon import adapter as adapter_mod
|
||||
|
||||
monkeypatch.setattr(adapter_mod.shutil, "which", lambda _name: None)
|
||||
assert adapter_mod.check_requirements() is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CAF attachment promotion + U+FFFC placeholder tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CAF_BYTES = b"caff" + b"\x00" * 60 # Minimal CAF header magic
|
||||
|
||||
|
||||
def _caf_attachment_event(
|
||||
content: Dict[str, Any], msg_id: str = "spc-msg-caf"
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"messageId": msg_id,
|
||||
"space": {"id": "+155****4567", "type": "dm", "phone": "+155****4567"},
|
||||
"sender": {"id": "+155****4567"},
|
||||
"content": {"type": "attachment", **content},
|
||||
"timestamp": "2026-05-14T19:06:32.000Z",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_caf_attachment_named_promoted_to_voice(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A named .caf attachment is promoted to VOICE for STT routing."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
|
||||
raw = _CAF_BYTES
|
||||
event = _caf_attachment_event(
|
||||
{
|
||||
"name": "voice_note.caf",
|
||||
"mimeType": "audio/x-caf",
|
||||
"size": len(raw),
|
||||
"data": base64.b64encode(raw).decode("ascii"),
|
||||
"encoding": "base64",
|
||||
}
|
||||
)
|
||||
await adapter._dispatch_inbound(event)
|
||||
|
||||
assert len(captured) == 1
|
||||
ev = captured[0]
|
||||
assert ev.message_type == MessageType.VOICE
|
||||
assert ev.media_types == ["audio/x-caf"]
|
||||
assert len(ev.media_urls) == 1
|
||||
cached = Path(ev.media_urls[0])
|
||||
try:
|
||||
assert cached.is_file()
|
||||
assert cached.read_bytes() == raw
|
||||
assert ev.text == "(voice)"
|
||||
finally:
|
||||
cached.unlink(missing_ok=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fffc_placeholder_no_dispatch(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A U+FFFC placeholder text does not trigger a message dispatch."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
|
||||
event = _dm_event("\ufffc", msg_id="spc-msg-fffc")
|
||||
chat_key = event["space"]["id"]
|
||||
await adapter._dispatch_inbound(event)
|
||||
|
||||
assert len(captured) == 0
|
||||
assert chat_key in adapter._pending_fffc
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_cancels_pending_fffc_tasks(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""disconnect() cancels any pending U+FFFC placeholder tasks."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
_capture(adapter, monkeypatch)
|
||||
|
||||
await adapter._dispatch_inbound(_dm_event("\ufffc", msg_id="spc-msg-fffc"))
|
||||
assert len(adapter._pending_fffc) == 1
|
||||
|
||||
async def _noop_stop_sidecar():
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(adapter, "_stop_sidecar", _noop_stop_sidecar)
|
||||
monkeypatch.setattr(adapter, "_inbound_running", False)
|
||||
monkeypatch.setattr(adapter, "_inbound_task", None)
|
||||
monkeypatch.setattr(adapter, "_sidecar_health_task", None)
|
||||
monkeypatch.setattr(adapter, "_http_client", None)
|
||||
|
||||
await adapter.disconnect()
|
||||
|
||||
assert len(adapter._pending_fffc) == 0
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Markdown handling tests for PhotonAdapter.
|
||||
|
||||
Markdown is on by default (the sidecar sends it via spectrum-ts'
|
||||
``markdown()`` builder and iMessage renders it); ``PHOTON_MARKDOWN=false``
|
||||
reverts to the stripped-plain-text path.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from plugins.platforms.photon import adapter as photon_adapter
|
||||
from plugins.platforms.photon.adapter import PhotonAdapter
|
||||
|
||||
_MD = "**bold** and `code`"
|
||||
|
||||
|
||||
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
return PhotonAdapter(cfg)
|
||||
|
||||
|
||||
def _capture_sidecar(adapter: PhotonAdapter) -> List[Tuple[str, Dict[str, Any]]]:
|
||||
calls: List[Tuple[str, Dict[str, Any]]] = []
|
||||
|
||||
async def _fake_call(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
calls.append((path, body))
|
||||
return {"ok": True, "messageId": "msg-123"}
|
||||
|
||||
adapter._sidecar_call = _fake_call # type: ignore[assignment]
|
||||
return calls
|
||||
|
||||
|
||||
def test_format_message_passthrough_by_default(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
assert adapter.format_message(_MD) == _MD
|
||||
|
||||
|
||||
def test_supports_code_blocks_mirrors_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
|
||||
assert _make_adapter(monkeypatch).supports_code_blocks is True
|
||||
monkeypatch.setenv("PHOTON_MARKDOWN", "false")
|
||||
assert _make_adapter(monkeypatch).supports_code_blocks is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sidecar_send_includes_markdown_format(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
await adapter.send("+15551234567", _MD)
|
||||
|
||||
path, body = calls[0]
|
||||
assert path == "/send"
|
||||
assert body["format"] == "markdown"
|
||||
assert body["text"] == _MD # passed through unstripped
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_standalone_send_includes_markdown_format(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
|
||||
monkeypatch.setenv("PHOTON_SIDECAR_TOKEN", "tok")
|
||||
|
||||
posted: List[Tuple[str, Dict[str, Any]]] = []
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
|
||||
@staticmethod
|
||||
def json() -> Dict[str, Any]:
|
||||
return {"ok": True, "messageId": "m-9"}
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, *a, **k):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
async def post(self, url: str, json: Dict[str, Any], headers=None):
|
||||
posted.append((url, json))
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _FakeClient)
|
||||
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
result = await photon_adapter._standalone_send(cfg, "+15551234567", _MD)
|
||||
|
||||
assert result.get("success") is True
|
||||
assert posted[0][1]["format"] == "markdown"
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Group-chat mention-gating tests for PhotonAdapter.
|
||||
|
||||
Parity with the BlueBubbles iMessage channel: when ``require_mention`` is
|
||||
enabled, group messages are dropped unless they hit a wake-word pattern,
|
||||
and the leading wake word is stripped from the ones that pass. DMs are
|
||||
never gated.
|
||||
|
||||
These call ``_dispatch_inbound`` directly (no aiohttp / ports) and assert
|
||||
on what reaches ``handle_message``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent
|
||||
from plugins.platforms.photon.adapter import PhotonAdapter
|
||||
|
||||
|
||||
def _make_adapter(monkeypatch: pytest.MonkeyPatch, extra: dict | None = None) -> PhotonAdapter:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
|
||||
monkeypatch.delenv("PHOTON_REQUIRE_MENTION", raising=False)
|
||||
monkeypatch.delenv("PHOTON_MENTION_PATTERNS", raising=False)
|
||||
cfg = PlatformConfig(enabled=True, token="", extra=extra or {})
|
||||
return PhotonAdapter(cfg)
|
||||
|
||||
|
||||
def _group_payload(text: str) -> dict:
|
||||
return {
|
||||
"messageId": f"grp-{abs(hash(text))}",
|
||||
"space": {"id": "group-guid-xyz", "type": "group", "phone": None},
|
||||
"sender": {"id": "+15551234567"},
|
||||
"content": {"type": "text", "text": text},
|
||||
"timestamp": "2026-05-14T19:06:32.000Z",
|
||||
}
|
||||
|
||||
|
||||
def _dm_payload(text: str) -> dict:
|
||||
return {
|
||||
"messageId": f"dm-{abs(hash(text))}",
|
||||
"space": {"id": "+15551234567", "type": "dm", "phone": "+15551234567"},
|
||||
"sender": {"id": "+15551234567"},
|
||||
"content": {"type": "text", "text": text},
|
||||
"timestamp": "2026-05-14T19:06:32.000Z",
|
||||
}
|
||||
|
||||
|
||||
def _capture(adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch) -> List[MessageEvent]:
|
||||
captured: List[MessageEvent] = []
|
||||
|
||||
async def fake_handle(event: MessageEvent) -> None:
|
||||
captured.append(event)
|
||||
|
||||
monkeypatch.setattr(adapter, "handle_message", fake_handle)
|
||||
return captured
|
||||
|
||||
|
||||
def test_require_mention_defaults_off(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
assert adapter.require_mention is False
|
||||
# Defaults compile to the two Hermes wake-word patterns.
|
||||
assert len(adapter._mention_patterns) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_message_dropped_without_mention(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch, extra={"require_mention": True})
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
|
||||
await adapter._dispatch_inbound(_group_payload("just chatting, no wake word"))
|
||||
assert captured == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_never_gated(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch, extra={"require_mention": True})
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
|
||||
await adapter._dispatch_inbound(_dm_payload("no wake word here"))
|
||||
assert len(captured) == 1
|
||||
assert captured[0].text == "no wake word here"
|
||||
|
||||
|
||||
def test_custom_mention_patterns_from_config(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(
|
||||
monkeypatch,
|
||||
extra={"require_mention": True, "mention_patterns": [r"(?<![\w@])@?amos\b[,:\-]?"]},
|
||||
)
|
||||
assert adapter.require_mention is True
|
||||
assert len(adapter._mention_patterns) == 1
|
||||
assert adapter._message_matches_mention_patterns("amos help me") is True
|
||||
assert adapter._message_matches_mention_patterns("hermes help me") is False
|
||||
|
||||
|
||||
def test_mention_patterns_env_comma_separated(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
|
||||
monkeypatch.setenv("PHOTON_REQUIRE_MENTION", "true")
|
||||
monkeypatch.setenv("PHOTON_MENTION_PATTERNS", r"bot\b, assistant\b")
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
adapter = PhotonAdapter(cfg)
|
||||
assert adapter.require_mention is True
|
||||
assert len(adapter._mention_patterns) == 2
|
||||
assert adapter._message_matches_mention_patterns("hey bot") is True
|
||||
|
||||
|
||||
def test_invalid_pattern_skipped(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(
|
||||
monkeypatch,
|
||||
extra={"require_mention": True, "mention_patterns": ["(unclosed", r"good\b"]},
|
||||
)
|
||||
# Bad regex dropped, good one kept.
|
||||
assert len(adapter._mention_patterns) == 1
|
||||
assert adapter._message_matches_mention_patterns("a good thing") is True
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Multiplex secondary-profile scope tests for the Photon adapter + auth module.
|
||||
|
||||
__init__'s project_id, check_requirements'/validate_config's node_bin/
|
||||
project_id, _env_enablement's home_channel, _reactions_enabled's
|
||||
PHOTON_REACTIONS, __init__'s require_mention, and _standalone_send's
|
||||
sidecar_port, plus auth.py's load_project_credentials/
|
||||
load_dashboard_project_id, all previously read raw os.getenv
|
||||
unconditionally (only PHOTON_PROJECT_SECRET/PHOTON_SIDECAR_TOKEN were
|
||||
already scoped via _get_scoped_secret). Under gateway.multiplex_profiles,
|
||||
os.environ holds the DEFAULT profile's YAML-to-env bridge output -- a
|
||||
secondary profile with its own (different or absent) Photon config could
|
||||
silently authenticate against the default profile's Spectrum project, or
|
||||
have its mention-gating/reaction behavior driven by the default profile's
|
||||
settings.
|
||||
|
||||
Notably project_id was a stronger variant of the bug (like the IRC fix in
|
||||
this series): __init__'s original
|
||||
`os.getenv("PHOTON_PROJECT_ID") or extra.get("project_id") or stored_id`
|
||||
ordering let a raw env read override even an explicitly configured
|
||||
config.yaml extra.
|
||||
|
||||
Mirrors the LINE/DingTalk/IRC/Mattermost fix for #98738.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from plugins.platforms.photon import auth as photon_auth
|
||||
from plugins.platforms.photon.adapter import PhotonAdapter
|
||||
|
||||
_PHOTON_ENV = (
|
||||
"PHOTON_PROJECT_ID",
|
||||
"PHOTON_PROJECT_SECRET",
|
||||
"PHOTON_DASHBOARD_PROJECT_ID",
|
||||
"PHOTON_REQUIRE_MENTION",
|
||||
"PHOTON_REACTIONS",
|
||||
"PHOTON_HOME_CHANNEL",
|
||||
"PHOTON_HOME_CHANNEL_NAME",
|
||||
"PHOTON_SIDECAR_PORT",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_hermes_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Isolate from the real ~/.hermes/auth.json fallback in load_project_credentials()."""
|
||||
home = tmp_path / "hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
for key in _PHOTON_ENV:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
yield home
|
||||
for key in _PHOTON_ENV:
|
||||
os.environ.pop(key, None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def multiplex_scope():
|
||||
"""Install multiplex + a secondary-profile secret scope; restore after."""
|
||||
tokens = []
|
||||
|
||||
def install(scope=None):
|
||||
from agent.secret_scope import set_multiplex_active, set_secret_scope
|
||||
|
||||
set_multiplex_active(True)
|
||||
tokens.append(set_secret_scope(scope or {}))
|
||||
return tokens[-1]
|
||||
|
||||
yield install
|
||||
|
||||
from agent.secret_scope import reset_secret_scope, set_multiplex_active
|
||||
|
||||
for token in reversed(tokens):
|
||||
reset_secret_scope(token)
|
||||
set_multiplex_active(False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def default_profile_env(monkeypatch):
|
||||
"""The default profile's YAML-to-env bridge output in os.environ."""
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "default-project-id")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "default-project-secret")
|
||||
monkeypatch.setenv("PHOTON_REQUIRE_MENTION", "true")
|
||||
monkeypatch.setenv("PHOTON_REACTIONS", "true")
|
||||
|
||||
|
||||
class TestAuthMultiplexProfileScope:
|
||||
"""load_project_credentials / load_dashboard_project_id (auth.py)."""
|
||||
|
||||
def test_scoped_miss_does_not_leak_default_project_id(
|
||||
self, tmp_hermes_home, multiplex_scope, default_profile_env
|
||||
):
|
||||
multiplex_scope({"SOMETHING_ELSE": "x"})
|
||||
sid, secret = photon_auth.load_project_credentials()
|
||||
assert sid is None
|
||||
assert secret is None
|
||||
adapter = PhotonAdapter(PlatformConfig(enabled=True, extra={}))
|
||||
assert adapter._project_id == ""
|
||||
assert adapter.require_mention is False
|
||||
assert adapter._reactions_enabled() is False
|
||||
|
||||
class TestAdapterMultiplexProfileScope:
|
||||
"""PhotonAdapter.__init__ / _env_enablement / _reactions_enabled (adapter.py)."""
|
||||
|
||||
def test_secondary_extra_wins_over_default_profile_env(
|
||||
self, tmp_hermes_home, multiplex_scope, default_profile_env
|
||||
):
|
||||
"""A secondary profile's own config.yaml extra project_id must be
|
||||
authoritative -- not the default profile's bridged env value. The
|
||||
pre-fix ordering (raw os.getenv checked BEFORE extra) meant even an
|
||||
explicit extra config was silently overridden."""
|
||||
multiplex_scope({"PHOTON_PROJECT_SECRET": "profile-secret"})
|
||||
cfg = PlatformConfig(
|
||||
enabled=True,
|
||||
extra={"project_id": "profile-project-id"},
|
||||
)
|
||||
adapter = PhotonAdapter(cfg)
|
||||
assert adapter._project_id == "profile-project-id"
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Regression tests for the npm stderr capture + error log persistence fix.
|
||||
|
||||
Each test covers a specific failure vector introduced by the Risk 3 solution:
|
||||
|
||||
1. _install_sidecar() return code unchanged — still 0 on success, non-zero on failure
|
||||
2. _install_sidecar() with no npm on PATH — still returns 1, no OSError on log write
|
||||
3. _NPM_ERROR_LOG write fails (OSError / read-only fs) — silently handled, no exception
|
||||
4. _NPM_ERROR_LOG read fails in check_requirements() — silently handled, returns False
|
||||
5. _NPM_ERROR_LOG is empty string — not written, check_requirements() falls back gracefully
|
||||
6. _NPM_ERROR_LOG from prior failed run exists when next run succeeds — cleared
|
||||
7. check_requirements() with no _NPM_ERROR_LOG — debug log still emitted without error detail
|
||||
8. proc.stderr is None (edge case on some platforms) — no AttributeError, no crash
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.platforms.photon import adapter as adapter_mod
|
||||
from plugins.platforms.photon import cli as cli_mod
|
||||
|
||||
_NODE_ON_PATH = __import__("shutil").which("node") is not None
|
||||
_requires_node = pytest.mark.skipif(
|
||||
not _NODE_ON_PATH, reason="requires node on PATH"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Return code contract unchanged
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_regression_return_code_zero_on_success(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""_install_sidecar() must still return 0 on npm success."""
|
||||
monkeypatch.setattr(cli_mod.shutil, "which", lambda _: "/usr/bin/npm")
|
||||
monkeypatch.setattr(
|
||||
cli_mod.subprocess, "run",
|
||||
lambda cmd, **kw: types.SimpleNamespace(returncode=0, stderr=""),
|
||||
)
|
||||
monkeypatch.setattr(cli_mod, "_NPM_ERROR_LOG", tmp_path / ".photon-npm-error.log")
|
||||
assert cli_mod._install_sidecar() == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. OSError on log write — silently swallowed, no crash
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_regression_oserror_on_log_write_does_not_propagate(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""If writing _NPM_ERROR_LOG raises OSError (read-only fs, permission denied),
|
||||
_install_sidecar() must NOT propagate the exception — it still returns the
|
||||
npm exit code."""
|
||||
def _bad_log_write(*args, **kwargs):
|
||||
raise OSError("read-only file system")
|
||||
|
||||
error_log = tmp_path / ".photon-npm-error.log"
|
||||
# Monkey-patch write_text on the Path object via a subclass
|
||||
class _UnwritablePath(type(error_log)):
|
||||
def write_text(self, *a, **kw):
|
||||
raise OSError("read-only file system")
|
||||
def unlink(self, *a, **kw):
|
||||
raise OSError("read-only file system")
|
||||
def exists(self):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(cli_mod.shutil, "which", lambda _: "/usr/bin/npm")
|
||||
monkeypatch.setattr(
|
||||
cli_mod.subprocess, "run",
|
||||
lambda cmd, **kw: types.SimpleNamespace(returncode=1, stderr="npm ERR!"),
|
||||
)
|
||||
monkeypatch.setattr(cli_mod, "_NPM_ERROR_LOG", _UnwritablePath(error_log))
|
||||
|
||||
rc = cli_mod._install_sidecar()
|
||||
assert rc == 1 # still returns the npm exit code
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. OSError on log read in check_requirements() — silently swallowed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Empty stderr — log file NOT written
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_regression_empty_stderr_does_not_write_log(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""If npm fails but stderr is empty (some npm versions), _NPM_ERROR_LOG must
|
||||
NOT be written — an empty file would mislead check_requirements()."""
|
||||
error_log = tmp_path / ".photon-npm-error.log"
|
||||
monkeypatch.setattr(cli_mod.shutil, "which", lambda _: "/usr/bin/npm")
|
||||
monkeypatch.setattr(
|
||||
cli_mod.subprocess, "run",
|
||||
lambda cmd, **kw: types.SimpleNamespace(returncode=1, stderr=""),
|
||||
)
|
||||
monkeypatch.setattr(cli_mod, "_NPM_ERROR_LOG", error_log)
|
||||
|
||||
cli_mod._install_sidecar()
|
||||
|
||||
assert not error_log.exists(), (
|
||||
"_NPM_ERROR_LOG must not be created when stderr is empty"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. proc.stderr is None — no AttributeError
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_regression_permissionerror_on_success_unlink_does_not_propagate(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""A successful npm install must still return 0 even if deleting the
|
||||
stale _NPM_ERROR_LOG raises something other than FileNotFoundError
|
||||
(e.g. PermissionError on a locked file) — the unlink is best-effort."""
|
||||
error_log = tmp_path / ".photon-npm-error.log"
|
||||
|
||||
class _UnremovablePath(type(error_log)):
|
||||
def unlink(self, *a, **kw):
|
||||
raise PermissionError("access denied")
|
||||
|
||||
monkeypatch.setattr(cli_mod.shutil, "which", lambda _: "/usr/bin/npm")
|
||||
monkeypatch.setattr(
|
||||
cli_mod.subprocess, "run",
|
||||
lambda cmd, **kw: types.SimpleNamespace(returncode=0, stderr=""),
|
||||
)
|
||||
monkeypatch.setattr(cli_mod, "_NPM_ERROR_LOG", _UnremovablePath(error_log))
|
||||
|
||||
rc = cli_mod._install_sidecar()
|
||||
assert rc == 0 # PermissionError on cleanup must not fail the install
|
||||
|
||||
|
||||
def test_regression_long_stderr_truncated_before_write(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""A huge npm stderr must be bounded before it hits disk, not just when
|
||||
read back later — otherwise a verbose npm failure writes an unbounded
|
||||
file to the sidecar directory on every retry."""
|
||||
error_log = tmp_path / ".photon-npm-error.log"
|
||||
huge_stderr = "npm ERR! " + ("x" * 10_000)
|
||||
|
||||
monkeypatch.setattr(cli_mod.shutil, "which", lambda _: "/usr/bin/npm")
|
||||
monkeypatch.setattr(
|
||||
cli_mod.subprocess, "run",
|
||||
lambda cmd, **kw: types.SimpleNamespace(returncode=1, stderr=huge_stderr),
|
||||
)
|
||||
monkeypatch.setattr(cli_mod, "_NPM_ERROR_LOG", error_log)
|
||||
|
||||
cli_mod._install_sidecar()
|
||||
|
||||
written = error_log.read_text(encoding="utf-8")
|
||||
assert len(written) <= cli_mod._NPM_ERROR_LOG_MAX_CHARS
|
||||
|
||||
|
||||
def test_regression_none_stderr_does_not_crash(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""On some platforms/configurations proc.stderr can be None even with
|
||||
stderr=PIPE (e.g. encoding errors). _install_sidecar() must handle this."""
|
||||
monkeypatch.setattr(cli_mod.shutil, "which", lambda _: "/usr/bin/npm")
|
||||
monkeypatch.setattr(
|
||||
cli_mod.subprocess, "run",
|
||||
lambda cmd, **kw: types.SimpleNamespace(returncode=1, stderr=None),
|
||||
)
|
||||
monkeypatch.setattr(cli_mod, "_NPM_ERROR_LOG", tmp_path / ".photon-npm-error.log")
|
||||
|
||||
rc = cli_mod._install_sidecar()
|
||||
assert rc == 1 # must not raise AttributeError
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Stale log cleared on success — no phantom errors after reinstall
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_regression_stale_log_not_surfaced_after_successful_reinstall(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""If npm install succeeds on a retry but a stale _NPM_ERROR_LOG from the
|
||||
prior failed run still exists, check_requirements() must NOT surface the
|
||||
stale error after the successful reinstall clears it."""
|
||||
error_log = tmp_path / ".photon-npm-error.log"
|
||||
error_log.write_text("stale: npm ERR! old failure", encoding="utf-8")
|
||||
|
||||
# Successful reinstall clears the log
|
||||
monkeypatch.setattr(cli_mod.shutil, "which", lambda _: "/usr/bin/npm")
|
||||
monkeypatch.setattr(
|
||||
cli_mod.subprocess, "run",
|
||||
lambda cmd, **kw: types.SimpleNamespace(returncode=0, stderr=""),
|
||||
)
|
||||
monkeypatch.setattr(cli_mod, "_NPM_ERROR_LOG", error_log)
|
||||
cli_mod._install_sidecar()
|
||||
assert not error_log.exists(), "Success must clear the stale error log"
|
||||
|
||||
# Now check_requirements() must not mention the old error
|
||||
# Create spectrum-ts inside node_modules/ — the content check requires it.
|
||||
(tmp_path / "node_modules" / "spectrum-ts").mkdir(parents=True)
|
||||
monkeypatch.setattr(adapter_mod, "HTTPX_AVAILABLE", True)
|
||||
monkeypatch.setattr(adapter_mod, "_SIDECAR_DIR", tmp_path)
|
||||
monkeypatch.setattr(adapter_mod, "_NPM_ERROR_LOG", error_log)
|
||||
|
||||
with caplog.at_level(logging.DEBUG, logger="plugins.platforms.photon.adapter"):
|
||||
result = adapter_mod.check_requirements()
|
||||
|
||||
assert result is True
|
||||
assert not any("stale" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. check_requirements() without error log — debug log still emitted
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@_requires_node
|
||||
def test_regression_debug_log_emitted_even_without_error_log(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""When node_modules is missing and no _NPM_ERROR_LOG exists (first-time
|
||||
setup, not a failed install), check_requirements() must still emit a DEBUG
|
||||
line pointing to the sidecar path."""
|
||||
monkeypatch.setattr(adapter_mod, "HTTPX_AVAILABLE", True)
|
||||
monkeypatch.setattr(adapter_mod, "_SIDECAR_DIR", tmp_path)
|
||||
monkeypatch.setattr(adapter_mod, "_NPM_ERROR_LOG", tmp_path / ".photon-npm-error.log")
|
||||
# NS-606: disable self-heal so the debug-log branch is reached.
|
||||
monkeypatch.setattr(adapter_mod, "_dir_writable", lambda _p: False)
|
||||
# node_modules NOT created, error log NOT created
|
||||
|
||||
with caplog.at_level(logging.DEBUG, logger="plugins.platforms.photon.adapter"):
|
||||
result = adapter_mod.check_requirements()
|
||||
|
||||
assert result is False
|
||||
debug_messages = [r.message for r in caplog.records if r.levelno == logging.DEBUG]
|
||||
assert any(str(tmp_path) in m for m in debug_messages), (
|
||||
f"Expected DEBUG with sidecar path even without error log, got: {debug_messages}"
|
||||
)
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Outbound-media tests for PhotonAdapter.
|
||||
|
||||
Photon ships outbound attachments via spectrum-ts' ``attachment()`` /
|
||||
``voice()`` content builders, reached through the Node sidecar's
|
||||
``/send-attachment`` endpoint. These tests stub ``_sidecar_call`` so we
|
||||
can assert the endpoint + body shape each ``send_*`` override produces
|
||||
without spawning Node or binding ports.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from plugins.platforms.photon import adapter as photon_adapter
|
||||
from plugins.platforms.photon.adapter import PhotonAdapter
|
||||
|
||||
|
||||
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
|
||||
monkeypatch.delenv("PHOTON_WEBHOOK_SECRET", raising=False)
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
return PhotonAdapter(cfg)
|
||||
|
||||
|
||||
def _capture_sidecar(adapter: PhotonAdapter) -> List[Tuple[str, Dict[str, Any]]]:
|
||||
"""Replace ``_sidecar_call`` with a recorder that returns a fixed id."""
|
||||
calls: List[Tuple[str, Dict[str, Any]]] = []
|
||||
|
||||
async def _fake_call(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
calls.append((path, body))
|
||||
return {"ok": True, "messageId": "msg-123"}
|
||||
|
||||
adapter._sidecar_call = _fake_call # type: ignore[assignment]
|
||||
return calls
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def real_file(tmp_path) -> str:
|
||||
p = tmp_path / "photo.jpg"
|
||||
p.write_bytes(b"\xff\xd8\xff\xe0fake-jpeg")
|
||||
return str(p)
|
||||
|
||||
|
||||
def _patch_safe_path(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Make path validation a passthrough so tmp files outside the cache pass."""
|
||||
monkeypatch.setattr(
|
||||
PhotonAdapter,
|
||||
"validate_media_delivery_path",
|
||||
staticmethod(lambda p: p if os.path.exists(p) else None),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_image_file_hits_attachment_endpoint(
|
||||
monkeypatch: pytest.MonkeyPatch, real_file: str
|
||||
) -> None:
|
||||
_patch_safe_path(monkeypatch)
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
result = await adapter.send_image_file(
|
||||
"any;-;+15551234567", real_file, caption="look"
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.message_id == "msg-123"
|
||||
assert len(calls) == 1
|
||||
path, body = calls[0]
|
||||
assert path == "/send-attachment"
|
||||
assert body["spaceId"] == "any;-;+15551234567"
|
||||
assert body["path"] == real_file
|
||||
assert body["kind"] == "attachment"
|
||||
assert body["caption"] == "look"
|
||||
assert body["mimeType"] == "image/jpeg" # inferred from .jpg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_standalone_send_text_then_attachments(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
_patch_safe_path(monkeypatch)
|
||||
img = tmp_path / "a.png"
|
||||
img.write_bytes(b"\x89PNG fake")
|
||||
monkeypatch.setenv("PHOTON_SIDECAR_TOKEN", "tok")
|
||||
|
||||
posted: List[Tuple[str, Dict[str, Any]]] = []
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
|
||||
@staticmethod
|
||||
def json() -> Dict[str, Any]:
|
||||
return {"ok": True, "messageId": "m-9"}
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, *a, **k):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
async def post(self, url: str, json: Dict[str, Any], headers=None):
|
||||
posted.append((url, json))
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _FakeClient)
|
||||
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
result = await photon_adapter._standalone_send(
|
||||
cfg,
|
||||
"any;-;+1",
|
||||
"hello",
|
||||
media_files=[(str(img), False)],
|
||||
)
|
||||
|
||||
assert result.get("success") is True
|
||||
# First call is the text /send, second is /send-attachment.
|
||||
assert posted[0][0].endswith("/send")
|
||||
assert posted[0][1]["text"] == "hello"
|
||||
assert posted[1][0].endswith("/send-attachment")
|
||||
assert posted[1][1]["path"] == str(img)
|
||||
assert posted[1][1]["kind"] == "attachment"
|
||||
assert posted[1][1]["mimeType"] == "image/png"
|
||||
@@ -0,0 +1,495 @@
|
||||
"""Photon adapter resilience to transient Spectrum/Envoy upstream overflow.
|
||||
|
||||
Covers the three behaviors that let the adapter ride through a Photon
|
||||
"reset reason: overflow" event instead of degrading delivery and silently
|
||||
dying (issue #50185):
|
||||
|
||||
1. ``_is_retryable_error`` classifies the Envoy/sidecar overflow strings as
|
||||
retryable so ``_send_with_retry`` actually engages its backoff loop.
|
||||
2. ``send_typing`` is rate-gated per chat, and ``stop_typing`` resets the
|
||||
gate so the next turn's typing indicator fires immediately.
|
||||
3. ``_supervise_sidecar`` detects an unexpected sidecar exit and raises a
|
||||
``retryable=True`` fatal so the gateway reconnect watcher revives the
|
||||
platform — instead of returning silently and leaving ``_inbound_loop``
|
||||
spinning against a dead port.
|
||||
4. ``_monitor_sidecar_health`` promotes degraded upstream stream health
|
||||
reported by ``/healthz`` into the same retryable reconnect path.
|
||||
|
||||
No Node sidecar is spawned and no ports are bound.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Dict
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.base import SendResult
|
||||
from plugins.platforms.photon import adapter as photon_adapter
|
||||
from plugins.platforms.photon.adapter import PhotonAdapter
|
||||
|
||||
|
||||
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
return PhotonAdapter(cfg)
|
||||
|
||||
|
||||
# -- Gap 1: retryable classification of overflow errors ---------------------
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
"UNAVAILABLE: internal sidecar error",
|
||||
"upstream connect error or disconnect/reset before headers",
|
||||
"reset reason: overflow",
|
||||
# Case-insensitive: real strings arrive with mixed case.
|
||||
"Internal Sidecar Error",
|
||||
],
|
||||
)
|
||||
def test_overflow_strings_classified_retryable(error: str) -> None:
|
||||
assert PhotonAdapter._is_retryable_error(error) is True
|
||||
|
||||
|
||||
def test_unrelated_error_not_retryable() -> None:
|
||||
# A genuine permanent failure must NOT be retried.
|
||||
assert PhotonAdapter._is_retryable_error("400 bad request: invalid spaceId") is False
|
||||
assert PhotonAdapter._is_retryable_error(None) is False
|
||||
|
||||
|
||||
def test_base_network_patterns_still_match() -> None:
|
||||
# The override delegates to the base classifier first, so generic
|
||||
# network strings keep working.
|
||||
assert PhotonAdapter._is_retryable_error("ConnectError: connection refused") is True
|
||||
|
||||
|
||||
def test_structured_non_retryable_sidecar_error_not_legacy_retried() -> None:
|
||||
error = str(
|
||||
photon_adapter.PhotonSidecarError(
|
||||
path="/send",
|
||||
status_code=500,
|
||||
error="internal sidecar error",
|
||||
error_class="auth_or_config",
|
||||
retryable=False,
|
||||
)
|
||||
)
|
||||
|
||||
assert PhotonAdapter._is_retryable_error(error) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_with_retry_uses_structured_retryable_flag(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = 0
|
||||
sleeps: list[float] = []
|
||||
|
||||
async def _fake_sleep(delay: float) -> None:
|
||||
sleeps.append(delay)
|
||||
|
||||
async def _fake_sidecar_call(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
raise photon_adapter.PhotonSidecarError(
|
||||
path=path,
|
||||
status_code=500,
|
||||
error="temporary upstream failure",
|
||||
error_class="upstream_transient",
|
||||
retryable=True,
|
||||
)
|
||||
return {"ok": True, "messageId": "m-2"}
|
||||
|
||||
monkeypatch.setattr(photon_adapter.asyncio, "sleep", _fake_sleep)
|
||||
monkeypatch.setattr(adapter, "_sidecar_call", _fake_sidecar_call)
|
||||
|
||||
result = await adapter._send_with_retry(
|
||||
"space-1", "hello", max_retries=1, base_delay=0.25
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.message_id == "m-2"
|
||||
assert calls == 2
|
||||
assert sleeps == [0.25]
|
||||
|
||||
|
||||
# -- Gap 2: typing-indicator cooldown ---------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_typing_cooldown_suppresses_rapid_repeats(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls: list[Dict[str, Any]] = []
|
||||
|
||||
async def _fake_call(path: str, payload: Dict[str, Any]) -> Any:
|
||||
calls.append(payload)
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(adapter, "_sidecar_call", _fake_call)
|
||||
|
||||
# First call fires; immediate repeats are suppressed by the cooldown.
|
||||
await adapter.send_typing("chat-1")
|
||||
await adapter.send_typing("chat-1")
|
||||
await adapter.send_typing("chat-1")
|
||||
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_typing_resets_cooldown(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
starts = 0
|
||||
|
||||
async def _fake_call(path: str, payload: Dict[str, Any]) -> Any:
|
||||
nonlocal starts
|
||||
if payload.get("state") == "start":
|
||||
starts += 1
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(adapter, "_sidecar_call", _fake_call)
|
||||
|
||||
# A start, then a stop (end of turn), then a start for the next turn must
|
||||
# fire immediately — the cooldown only suppresses rapid consecutive starts
|
||||
# without an intervening stop.
|
||||
await adapter.send_typing("chat-1")
|
||||
await adapter.stop_typing("chat-1")
|
||||
await adapter.send_typing("chat-1")
|
||||
|
||||
assert starts == 2
|
||||
|
||||
|
||||
# -- Gap 3: sidecar crash detection -----------------------------------------
|
||||
|
||||
class _EofStdout:
|
||||
"""A proc.stdout whose readline() reports immediate EOF (dead sidecar)."""
|
||||
|
||||
def readline(self) -> bytes:
|
||||
return b""
|
||||
|
||||
|
||||
class _DeadProc:
|
||||
"""Minimal subprocess.Popen stand-in for a sidecar that has exited."""
|
||||
|
||||
def __init__(self, exit_code: int = 1) -> None:
|
||||
self.stdout = _EofStdout()
|
||||
self.stdin = None
|
||||
self._exit_code = exit_code
|
||||
|
||||
def poll(self) -> int:
|
||||
return self._exit_code
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unexpected_sidecar_exit_raises_retryable_fatal(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
# Simulate a live session whose sidecar then dies underneath it.
|
||||
adapter._inbound_running = True
|
||||
|
||||
notified: list[bool] = []
|
||||
|
||||
async def _fake_notify() -> None:
|
||||
notified.append(True)
|
||||
|
||||
monkeypatch.setattr(adapter, "_notify_fatal_error", _fake_notify)
|
||||
|
||||
await adapter._supervise_sidecar(_DeadProc(exit_code=137)) # type: ignore[arg-type]
|
||||
|
||||
assert adapter.has_fatal_error is True
|
||||
assert adapter.fatal_error_code == "SIDECAR_CRASHED"
|
||||
# retryable=True routes the platform into the reconnect watcher rather
|
||||
# than crashing the whole gateway.
|
||||
assert adapter.fatal_error_retryable is True
|
||||
assert adapter._running is False
|
||||
# The notification is dispatched onto its own task rather than awaited on
|
||||
# the supervisor's stack, so that disconnect() cancelling the supervisor
|
||||
# cannot kill the handoff. Let that task run before asserting delivery.
|
||||
await _drain_pending_tasks()
|
||||
assert notified == [True]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clean_shutdown_does_not_raise_fatal(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
# disconnect() sets _inbound_running = False before stopping the sidecar,
|
||||
# so the detection block must NOT fire on a clean shutdown.
|
||||
adapter._inbound_running = False
|
||||
|
||||
notified: list[bool] = []
|
||||
|
||||
async def _fake_notify() -> None:
|
||||
notified.append(True)
|
||||
|
||||
monkeypatch.setattr(adapter, "_notify_fatal_error", _fake_notify)
|
||||
|
||||
await adapter._supervise_sidecar(_DeadProc(exit_code=0)) # type: ignore[arg-type]
|
||||
|
||||
assert adapter.has_fatal_error is False
|
||||
assert notified == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_degraded_stream_health_raises_retryable_fatal(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
adapter._inbound_running = True
|
||||
adapter._sidecar_health_interval = 0.0
|
||||
|
||||
async def _fake_call(path: str, payload: Dict[str, Any]) -> Any:
|
||||
assert path == "/healthz"
|
||||
return {
|
||||
"ok": True,
|
||||
"stream": {
|
||||
"ok": False,
|
||||
"state": "degraded",
|
||||
"degradedForMs": 120000,
|
||||
"lastIssue": "[spectrum.stream] stream interrupted; reconnecting",
|
||||
},
|
||||
}
|
||||
|
||||
notified: list[bool] = []
|
||||
|
||||
async def _fake_notify() -> None:
|
||||
notified.append(True)
|
||||
adapter._inbound_running = False
|
||||
|
||||
monkeypatch.setattr(adapter, "_sidecar_call", _fake_call)
|
||||
monkeypatch.setattr(adapter, "_notify_fatal_error", _fake_notify)
|
||||
|
||||
await adapter._monitor_sidecar_health()
|
||||
|
||||
assert adapter.has_fatal_error is True
|
||||
assert adapter.fatal_error_code == "UPSTREAM_STREAM_DEGRADED"
|
||||
assert adapter.fatal_error_retryable is True
|
||||
# Dispatched detached (see _dispatch_fatal_notification) so the health
|
||||
# task's own teardown cannot cancel the handoff; drain before asserting.
|
||||
await _drain_pending_tasks()
|
||||
assert notified == [True]
|
||||
|
||||
|
||||
async def _drain_pending_tasks(limit: int = 50) -> None:
|
||||
"""Let detached fatal-notification tasks finish before asserting on them.
|
||||
|
||||
``_dispatch_fatal_notification`` deliberately does not await the
|
||||
notification (that is what kept ``disconnect()`` from cancelling its own
|
||||
caller), so a test that drives ``_monitor_sidecar_health`` /
|
||||
``_supervise_sidecar`` directly returns before the notification has run.
|
||||
"""
|
||||
for _ in range(limit):
|
||||
pending = [
|
||||
t for t in asyncio.all_tasks()
|
||||
if t is not asyncio.current_task() and not t.done()
|
||||
]
|
||||
if not pending:
|
||||
return
|
||||
await asyncio.wait(pending, timeout=1.0)
|
||||
|
||||
|
||||
# -- Gap 5: self-cancellation race in _stop_sidecar() (issue #73159) --------
|
||||
#
|
||||
# The tests above mock out _notify_fatal_error() entirely, so the real
|
||||
# integration chain (supervisor task -> _notify_fatal_error() ->
|
||||
# disconnect() -> _stop_sidecar() -> cancel the supervisor task) is never
|
||||
# exercised end to end. That chain is exactly where the bug lives: when
|
||||
# _notify_fatal_error() is a real callback that calls adapter.disconnect(),
|
||||
# _stop_sidecar() is invoked FROM WITHIN the currently-running supervisor
|
||||
# task, and cancelling self._sidecar_supervisor_task there cancels the very
|
||||
# task executing the fatal-error handler -- aborting it (via CancelledError,
|
||||
# a BaseException the handler's `except Exception` guards don't catch)
|
||||
# before the Gateway's reconnect-queue step ever runs.
|
||||
|
||||
class _FakeStoppedProc:
|
||||
"""Minimal proc stand-in so _stop_sidecar() reaches its finally block
|
||||
(it early-returns entirely when self._sidecar_proc is None) without
|
||||
spawning a real subprocess or doing any real I/O."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.stdin = None
|
||||
|
||||
def wait(self, timeout: float | None = None) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_supervisor_task_survives_self_triggered_disconnect(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The real chain: _supervise_sidecar() (running as the actual
|
||||
self._sidecar_supervisor_task) detects the crash and calls a REAL
|
||||
_notify_fatal_error() that calls adapter.disconnect() -- which reaches
|
||||
_stop_sidecar() from inside the task it's about to try to cancel.
|
||||
|
||||
Before the fix: this raises CancelledError out of _supervise_sidecar(),
|
||||
so the task ends up in the "cancelled" state and reconnect_queued below
|
||||
is never set (mirroring how the real Gateway's fatal-error handler,
|
||||
which runs the reconnect-queue logic AFTER disconnect() returns, never
|
||||
gets there either).
|
||||
|
||||
After the fix: disconnect() completes normally, _supervise_sidecar()
|
||||
returns normally, and the task is NOT cancelled.
|
||||
"""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
adapter._inbound_running = True
|
||||
# A minimal fake proc so _stop_sidecar() reaches its finally block
|
||||
# (the real process-management behavior is covered separately by
|
||||
# test_sidecar_lifecycle.py) -- this test is purely about the
|
||||
# self-cancellation race.
|
||||
adapter._sidecar_proc = _FakeStoppedProc()
|
||||
|
||||
reconnect_queued: list[bool] = []
|
||||
|
||||
async def _real_notify_fatal_error() -> None:
|
||||
# Stand-in for the Gateway's actual fatal-error handler: it calls
|
||||
# adapter.disconnect() (real chain: disconnect -> _stop_sidecar,
|
||||
# which used to self-cancel), then -- only if that completes
|
||||
# without the CancelledError escaping -- proceeds to queue the
|
||||
# platform for background reconnection.
|
||||
await adapter.disconnect()
|
||||
reconnect_queued.append(True)
|
||||
|
||||
monkeypatch.setattr(adapter, "_notify_fatal_error", _real_notify_fatal_error)
|
||||
|
||||
async def _run_supervisor():
|
||||
await adapter._supervise_sidecar(_DeadProc(exit_code=75))
|
||||
|
||||
task = asyncio.ensure_future(_run_supervisor())
|
||||
adapter._sidecar_supervisor_task = task
|
||||
|
||||
# Must complete cleanly -- must NOT raise CancelledError out to us.
|
||||
await task
|
||||
|
||||
assert task.cancelled() is False, (
|
||||
"The supervisor task must not end up cancelled by its own "
|
||||
"fatal-error handling chain"
|
||||
)
|
||||
assert adapter.has_fatal_error is True
|
||||
assert adapter.fatal_error_code == "SIDECAR_CRASHED"
|
||||
assert reconnect_queued == [True], (
|
||||
"The reconnect-queue step (everything after disconnect() returns "
|
||||
"in the real Gateway handler) must actually run -- this is the "
|
||||
"exact step issue #73159 reports as silently skipped"
|
||||
)
|
||||
# _stop_sidecar() must have cleared the task reference either way.
|
||||
assert adapter._sidecar_supervisor_task is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_external_disconnect_still_cancels_supervisor(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The OTHER call path -- external cleanup (Gateway shutdown, an
|
||||
explicit /platform disconnect) -- calls _stop_sidecar() from a
|
||||
DIFFERENT task than the supervisor. That legitimate case must still
|
||||
cancel a still-running supervisor task exactly as before."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
adapter._sidecar_proc = _FakeStoppedProc()
|
||||
|
||||
supervisor_ran_forever = asyncio.Event()
|
||||
|
||||
async def _hangs_forever():
|
||||
try:
|
||||
supervisor_ran_forever.set()
|
||||
await asyncio.sleep(3600)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
||||
task = asyncio.ensure_future(_hangs_forever())
|
||||
adapter._sidecar_supervisor_task = task
|
||||
await supervisor_ran_forever.wait()
|
||||
|
||||
# Called from THIS (different) task -- the external-cleanup case.
|
||||
await adapter._stop_sidecar()
|
||||
|
||||
# cancel() only schedules the CancelledError; await it so the task
|
||||
# actually settles into the cancelled state before asserting.
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
assert task.cancelled() is True, (
|
||||
"External cleanup must still cancel a running supervisor task"
|
||||
)
|
||||
assert adapter._sidecar_supervisor_task is None
|
||||
|
||||
|
||||
# -- target_not_allowed: shared/free-tier outbound-send restriction ----------
|
||||
#
|
||||
# Spectrum throws AuthenticationError("Target not allowed for this project")
|
||||
# from space.send when a shared/free-tier line initiates an outbound send to
|
||||
# a new target. The sidecar classifies it as the structured code
|
||||
# `target_not_allowed`; the adapter must treat it as permanent in BOTH
|
||||
# _send_with_retry and _standalone_send, surfacing the canonical user-facing
|
||||
# message instead of raw upstream error text (issues #50971 / #51897).
|
||||
|
||||
|
||||
def test_target_not_allowed_maps_to_canonical_message() -> None:
|
||||
err = photon_adapter._sidecar_error_from_response(
|
||||
"/send",
|
||||
500,
|
||||
'{"ok":false,"error":"internal sidecar error",'
|
||||
'"error_class":"target_not_allowed","retryable":false}',
|
||||
)
|
||||
|
||||
assert err.error_class == "target_not_allowed"
|
||||
assert err.retryable is False
|
||||
assert err.error == photon_adapter._TARGET_NOT_ALLOWED_MESSAGE
|
||||
# No raw upstream text may leak through the structured code path.
|
||||
assert "Target not allowed for this project" not in str(err)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_standalone_send_classifies_target_not_allowed(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("PHOTON_SIDECAR_TOKEN", "token")
|
||||
|
||||
class _Resp:
|
||||
status_code = 500
|
||||
text = (
|
||||
'{"ok":false,"error":"internal sidecar error",'
|
||||
'"error_class":"target_not_allowed","retryable":false}'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def json() -> Dict[str, Any]:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "internal sidecar error",
|
||||
"error_class": "target_not_allowed",
|
||||
"retryable": False,
|
||||
}
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, *a: Any, **k: Any) -> None:
|
||||
pass
|
||||
|
||||
async def __aenter__(self) -> "_FakeClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a: Any) -> bool:
|
||||
return False
|
||||
|
||||
async def post(self, *a: Any, **k: Any) -> _Resp:
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _FakeClient)
|
||||
|
||||
result = await photon_adapter._standalone_send(
|
||||
PlatformConfig(enabled=True, extra={}), "space-1", "hello",
|
||||
)
|
||||
|
||||
assert result.get("error") == photon_adapter._TARGET_NOT_ALLOWED_MESSAGE
|
||||
assert result.get("error_class") == "target_not_allowed"
|
||||
assert result.get("retryable") is False
|
||||
assert "Target not allowed for this project" not in str(result)
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Native-poll clarify tests for PhotonAdapter.
|
||||
|
||||
iMessage has a native poll bubble (spectrum-ts `poll()` builder). A
|
||||
multiple-choice ``clarify`` renders as that poll; the user taps a choice and
|
||||
the vote streams back inbound as a ``poll_option`` event. These tests cover
|
||||
both directions without spawning the Node sidecar or binding ports:
|
||||
|
||||
* outbound — ``send_clarify`` with choices POSTs ``/send-poll`` and flips the
|
||||
clarify into text-capture mode; with no choices it stays plain text;
|
||||
* inbound — a ``poll_option`` selection is dispatched as a plain-text message
|
||||
carrying the chosen option (so the gateway clarify-intercept resolves it),
|
||||
a deselection is dropped, and an empty-title vote is dropped.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent, MessageType, SendResult
|
||||
from plugins.platforms.photon.adapter import PhotonAdapter
|
||||
|
||||
|
||||
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
return PhotonAdapter(cfg)
|
||||
|
||||
|
||||
def _capture(
|
||||
adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> List[MessageEvent]:
|
||||
captured: List[MessageEvent] = []
|
||||
|
||||
async def fake_handle(event: MessageEvent) -> None:
|
||||
captured.append(event)
|
||||
|
||||
monkeypatch.setattr(adapter, "handle_message", fake_handle)
|
||||
return captured
|
||||
|
||||
|
||||
def _poll_option_event(
|
||||
*, title: str, selected: bool = True, msg_id: str = "spc-msg-vote"
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"messageId": msg_id,
|
||||
"platform": "iMessage",
|
||||
"space": {"id": "+155****4567", "type": "dm", "phone": "+155****4567"},
|
||||
"sender": {"id": "+155****4567"},
|
||||
"content": {
|
||||
"type": "poll_option",
|
||||
"title": title,
|
||||
"selected": selected,
|
||||
"pollTitle": "Pick one",
|
||||
},
|
||||
"timestamp": "2026-05-14T19:06:32.000Z",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inbound: a poll vote becomes the clarify answer.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_vote_dispatched_as_choice_text(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A poll selection is forwarded as a plain-text message carrying the
|
||||
chosen option, so the gateway clarify text-intercept can resolve it."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
|
||||
await adapter._dispatch_inbound(
|
||||
_poll_option_event(title="Yes — native tappable buttons")
|
||||
)
|
||||
|
||||
assert len(captured) == 1
|
||||
ev = captured[0]
|
||||
assert ev.text == "Yes — native tappable buttons"
|
||||
assert ev.message_type == MessageType.TEXT
|
||||
assert ev.source.chat_id == "+155****4567"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Outbound: send_clarify renders a native poll for choices.
|
||||
|
||||
|
||||
def _stub_sidecar_poll(
|
||||
adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch, *, ok: bool = True
|
||||
) -> List[Tuple[str, str, list]]:
|
||||
calls: List[Tuple[str, str, list]] = []
|
||||
|
||||
async def fake_send_poll(space_id: str, title: str, options: list):
|
||||
calls.append((space_id, title, list(options)))
|
||||
return SendResult(
|
||||
success=ok,
|
||||
message_id="spc-msg-poll" if ok else None,
|
||||
error=None if ok else "boom",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(adapter, "_sidecar_send_poll", fake_send_poll)
|
||||
return calls
|
||||
|
||||
|
||||
def _stub_sidecar_text(
|
||||
adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> List[Tuple[str, str]]:
|
||||
sends: List[Tuple[str, str]] = []
|
||||
|
||||
async def fake_send(space_id: str, text: str):
|
||||
sends.append((space_id, text))
|
||||
return SendResult(success=True, message_id="spc-msg-text")
|
||||
|
||||
monkeypatch.setattr(adapter, "_sidecar_send", fake_send)
|
||||
return sends
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_clarify_with_choices_sends_native_poll(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
poll_calls = _stub_sidecar_poll(adapter, monkeypatch)
|
||||
|
||||
marked: List[str] = []
|
||||
import tools.clarify_gateway as cg
|
||||
|
||||
monkeypatch.setattr(cg, "mark_awaiting_text", lambda cid: marked.append(cid))
|
||||
|
||||
result = await adapter.send_clarify(
|
||||
chat_id="+155****4567",
|
||||
question="Pick one",
|
||||
choices=["A", "B", "C"],
|
||||
clarify_id="clar-1",
|
||||
session_key="sess-1",
|
||||
)
|
||||
|
||||
assert result.success
|
||||
assert len(poll_calls) == 1
|
||||
space_id, title, options = poll_calls[0]
|
||||
assert space_id == "+155****4567"
|
||||
assert title == "Pick one"
|
||||
assert options == ["A", "B", "C"]
|
||||
# The vote returns as text, so text-capture must be enabled.
|
||||
assert marked == ["clar-1"]
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Presence-watchdog tests.
|
||||
|
||||
spectrum-ts only reconnects when its inbound iterator throws or ends; a
|
||||
half-open ("zombie") gRPC socket makes the iterator hang forever (no error, no
|
||||
end), so inbound silently dies until the sidecar is restarted. The adapter's
|
||||
presence watchdog probes the upstream channel via the sidecar's ``/probe``
|
||||
endpoint and respawns the sidecar after repeated probe failures.
|
||||
|
||||
These tests exercise the watchdog's decision logic (probe -> count failures ->
|
||||
respawn; success resets; recent inbound traffic skips the probe) without
|
||||
spawning Node, binding ports, or hitting the network.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, List
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from plugins.platforms.photon.adapter import PhotonAdapter
|
||||
|
||||
|
||||
def _make_adapter(monkeypatch: pytest.MonkeyPatch, **extra: Any) -> PhotonAdapter:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
|
||||
cfg = PlatformConfig(enabled=True, token="", extra=dict(extra))
|
||||
return PhotonAdapter(cfg)
|
||||
|
||||
|
||||
def test_probe_config_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
a = _make_adapter(monkeypatch)
|
||||
# Conservative by default: probe only after 10+ minutes of stream silence
|
||||
# so quiet shared lines never trigger restart storms.
|
||||
assert a._probe_interval == 600.0
|
||||
assert a._probe_timeout == 10.0
|
||||
assert a._probe_max_failures == 3
|
||||
assert a._probe_enabled is True
|
||||
|
||||
|
||||
def test_note_activity_resets_failures(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
a = _make_adapter(monkeypatch)
|
||||
a._probe_failures = 2
|
||||
before = a._last_upstream_activity
|
||||
time.sleep(0.001)
|
||||
a._note_upstream_activity()
|
||||
assert a._probe_failures == 0
|
||||
assert a._last_upstream_activity > before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_respawn_after_max_failures(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The core fix: N consecutive dead probes -> exactly one respawn."""
|
||||
a = _make_adapter(monkeypatch, probe_max_failures=3)
|
||||
|
||||
respawns: List[str] = []
|
||||
|
||||
async def _fake_respawn(reason: str) -> None:
|
||||
respawns.append(reason)
|
||||
a._note_upstream_activity() # mirror real respawn (clears failures)
|
||||
|
||||
async def _hung_probe() -> str:
|
||||
return "hung"
|
||||
|
||||
monkeypatch.setattr(a, "_respawn_sidecar", _fake_respawn)
|
||||
monkeypatch.setattr(a, "_probe_once", _hung_probe)
|
||||
|
||||
# Simulate the watchdog's per-iteration decision logic directly (no sleeps).
|
||||
a._last_upstream_activity = time.monotonic() - 999 # force a probe each time
|
||||
for _ in range(3):
|
||||
verdict = await a._probe_once()
|
||||
assert verdict == "hung"
|
||||
a._probe_failures += 1
|
||||
if a._probe_failures >= a._probe_max_failures:
|
||||
await a._respawn_sidecar("test")
|
||||
|
||||
assert respawns == ["test"]
|
||||
assert a._probe_failures == 0 # reset by the (faked) respawn
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_resets_failure_count(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A live probe between dead ones prevents a respawn (failures reset)."""
|
||||
a = _make_adapter(monkeypatch, probe_max_failures=3)
|
||||
|
||||
respawns: List[str] = []
|
||||
|
||||
async def _fake_respawn(reason: str) -> None:
|
||||
respawns.append(reason)
|
||||
|
||||
monkeypatch.setattr(a, "_respawn_sidecar", _fake_respawn)
|
||||
|
||||
# Two failures, then a success, then two more failures: never hits 3 in a row.
|
||||
sequence = [False, False, True, False, False]
|
||||
for alive in sequence:
|
||||
if alive:
|
||||
a._note_upstream_activity()
|
||||
else:
|
||||
a._probe_failures += 1
|
||||
if a._probe_failures >= a._probe_max_failures:
|
||||
await a._respawn_sidecar("should-not-fire")
|
||||
|
||||
assert respawns == []
|
||||
assert a._probe_failures == 2
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Reaction (tapback) tests for PhotonAdapter.
|
||||
|
||||
Outbound reactions go through the sidecar's ``/react`` / ``/unreact``
|
||||
endpoints; these tests stub ``_sidecar_call`` to assert endpoint + body
|
||||
shape. Inbound reaction events are fed straight to ``_dispatch_inbound``.
|
||||
Neither path spawns the Node sidecar or binds ports.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent, MessageType, ProcessingOutcome
|
||||
from plugins.platforms.photon.adapter import PhotonAdapter
|
||||
|
||||
_EYES = "\U0001f440"
|
||||
_THUMBS_UP = "\U0001f44d"
|
||||
_THUMBS_DOWN = "\U0001f44e"
|
||||
|
||||
|
||||
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
return PhotonAdapter(cfg)
|
||||
|
||||
|
||||
def _capture_sidecar(adapter: PhotonAdapter) -> List[Tuple[str, Dict[str, Any]]]:
|
||||
calls: List[Tuple[str, Dict[str, Any]]] = []
|
||||
|
||||
async def _fake_call(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
calls.append((path, body))
|
||||
return {"ok": True, "messageId": "msg-123", "reactionId": "react-1"}
|
||||
|
||||
adapter._sidecar_call = _fake_call # type: ignore[assignment]
|
||||
return calls
|
||||
|
||||
|
||||
def _capture_handled(
|
||||
adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> List[MessageEvent]:
|
||||
captured: List[MessageEvent] = []
|
||||
|
||||
async def fake_handle(event: MessageEvent) -> None:
|
||||
captured.append(event)
|
||||
|
||||
monkeypatch.setattr(adapter, "handle_message", fake_handle)
|
||||
return captured
|
||||
|
||||
|
||||
def _message_event(adapter: PhotonAdapter) -> MessageEvent:
|
||||
return MessageEvent(
|
||||
text="hi",
|
||||
message_type=MessageType.TEXT,
|
||||
source=adapter.build_source(
|
||||
chat_id="+15551234567",
|
||||
chat_name="+15551234567",
|
||||
chat_type="dm",
|
||||
user_id="+15551234567",
|
||||
user_name=None,
|
||||
),
|
||||
message_id="target-msg-1",
|
||||
timestamp=datetime.now(tz=timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
def _reaction_event(
|
||||
emoji: str = "❤️",
|
||||
target_id: str = "bot-msg-1",
|
||||
target_direction: Any = "outbound",
|
||||
space_type: str = "dm",
|
||||
target_text: Any = "the bot's earlier reply",
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"messageId": "reaction-evt-1",
|
||||
"platform": "iMessage",
|
||||
"space": {"id": "+15551234567", "type": space_type, "phone": "+15551234567"},
|
||||
"sender": {"id": "+15551234567"},
|
||||
"content": {
|
||||
"type": "reaction",
|
||||
"emoji": emoji,
|
||||
"targetMessageId": target_id,
|
||||
"targetDirection": target_direction,
|
||||
# The sidecar always emits this key (hydrated reaction target);
|
||||
# null when the reacted-to message carried no text.
|
||||
"targetText": target_text,
|
||||
},
|
||||
"timestamp": "2026-06-11T10:00:00.000Z",
|
||||
}
|
||||
|
||||
|
||||
# -- Outbound: /react and /unreact body shapes ------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_reaction_posts_react(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
ok = await adapter._add_reaction("+15551234567", "target-msg-1", _EYES)
|
||||
|
||||
assert ok is True
|
||||
assert calls == [
|
||||
(
|
||||
"/react",
|
||||
{
|
||||
"spaceId": "+15551234567",
|
||||
"messageId": "target-msg-1",
|
||||
"emoji": _EYES,
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_reaction_posts_unreact(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
ok = await adapter._remove_reaction("+15551234567", "target-msg-1")
|
||||
|
||||
assert ok is True
|
||||
assert calls == [
|
||||
("/unreact", {"spaceId": "+15551234567", "messageId": "target-msg-1"})
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reaction_failure_is_soft(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
|
||||
async def _boom(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
raise RuntimeError("sidecar down")
|
||||
|
||||
adapter._sidecar_call = _boom # type: ignore[assignment]
|
||||
|
||||
assert await adapter._add_reaction("+1", "m", _EYES) is False
|
||||
assert await adapter._remove_reaction("+1", "m") is False
|
||||
|
||||
|
||||
# -- Lifecycle hooks ---------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hooks_noop_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("PHOTON_REACTIONS", raising=False)
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
event = _message_event(adapter)
|
||||
await adapter.on_processing_start(event)
|
||||
await adapter.on_processing_complete(event, ProcessingOutcome.SUCCESS)
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_processing_start_adds_eyes(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("PHOTON_REACTIONS", "true")
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
await adapter.on_processing_start(_message_event(adapter))
|
||||
|
||||
assert len(calls) == 1
|
||||
path, body = calls[0]
|
||||
assert path == "/react"
|
||||
assert body["emoji"] == _EYES
|
||||
assert body["messageId"] == "target-msg-1"
|
||||
|
||||
|
||||
# -- Inbound reaction routing ------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inbound_reaction_on_bot_message_routed(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture_handled(adapter, monkeypatch)
|
||||
|
||||
await adapter._dispatch_inbound(_reaction_event(emoji="❤️"))
|
||||
|
||||
assert len(captured) == 1
|
||||
event = captured[0]
|
||||
assert event.text == "reaction:added:❤️"
|
||||
assert event.message_type == MessageType.TEXT
|
||||
assert event.source.chat_id == "+15551234567"
|
||||
# The tapback correlates to the bot message it reacted to, so the gateway
|
||||
# can inject `[Replying to your previous message: "..."]` for context.
|
||||
assert event.reply_to_message_id == "bot-msg-1"
|
||||
assert event.reply_to_text == "the bot's earlier reply"
|
||||
assert event.reply_to_is_own_message is True
|
||||
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Rich-link handling tests for PhotonAdapter.
|
||||
|
||||
Photon's spectrum-ts SDK exposes a ``richlink()`` content builder for native
|
||||
URL previews. Hermes routes URL-only outbound messages to the sidecar's
|
||||
rich-link endpoint and preserves inbound rich-link URLs when Spectrum emits
|
||||
that content type.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent, MessageType
|
||||
from plugins.platforms.photon import adapter as photon_adapter
|
||||
from plugins.platforms.photon.adapter import PhotonAdapter
|
||||
|
||||
_URL = "https://example.com/article"
|
||||
|
||||
|
||||
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
return PhotonAdapter(cfg)
|
||||
|
||||
|
||||
def _capture_sidecar(adapter: PhotonAdapter) -> List[Tuple[str, Dict[str, Any]]]:
|
||||
calls: List[Tuple[str, Dict[str, Any]]] = []
|
||||
|
||||
async def _fake_call(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
calls.append((path, body))
|
||||
return {"ok": True, "messageId": "msg-123"}
|
||||
|
||||
adapter._sidecar_call = _fake_call # type: ignore[assignment]
|
||||
return calls
|
||||
|
||||
|
||||
def _capture_inbound(
|
||||
adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> List[MessageEvent]:
|
||||
captured: List[MessageEvent] = []
|
||||
|
||||
async def fake_handle(event: MessageEvent) -> None:
|
||||
captured.append(event)
|
||||
|
||||
monkeypatch.setattr(adapter, "handle_message", fake_handle)
|
||||
return captured
|
||||
|
||||
|
||||
def _dm_event(content: Dict[str, Any], msg_id: str = "spc-msg-rich") -> Dict[str, Any]:
|
||||
return {
|
||||
"messageId": msg_id,
|
||||
"platform": "iMessage",
|
||||
"space": {"id": "+155****4567", "type": "dm", "phone": "+155****4567"},
|
||||
"sender": {"id": "+155****4567"},
|
||||
"content": content,
|
||||
"timestamp": "2026-05-14T19:06:32.000Z",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_url_only_send_routes_to_richlink_endpoint(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
result = await adapter.send("+155****4567", _URL)
|
||||
|
||||
assert result.success is True
|
||||
assert calls == [("/send-richlink", {"spaceId": "+155****4567", "url": _URL})]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mixed_prose_url_stays_on_markdown_send(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
await adapter.send("+155****4567", f"Read this: {_URL}")
|
||||
|
||||
path, body = calls[0]
|
||||
assert path == "/send"
|
||||
assert body["format"] == "markdown"
|
||||
assert body["text"] == f"Read this: {_URL}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_malformed_url_like_send_stays_on_markdown_send(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
await adapter.send("+155****4567", "http://[::1")
|
||||
|
||||
path, body = calls[0]
|
||||
assert path == "/send"
|
||||
assert body["format"] == "markdown"
|
||||
assert body["text"] == "http://[::1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_markdown_link_stays_on_markdown_send(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
await adapter.send("+155****4567", f"[Read this]({_URL})")
|
||||
|
||||
path, body = calls[0]
|
||||
assert path == "/send"
|
||||
assert body["format"] == "markdown"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_url_only_send_falls_back_to_plain_send(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls: List[Tuple[str, Dict[str, Any]]] = []
|
||||
|
||||
async def _fake_call(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
calls.append((path, body))
|
||||
if path == "/send-richlink":
|
||||
raise RuntimeError("richlink unsupported")
|
||||
return {"ok": True, "messageId": "plain-msg"}
|
||||
|
||||
adapter._sidecar_call = _fake_call # type: ignore[assignment]
|
||||
|
||||
result = await adapter.send("+155****4567", _URL)
|
||||
|
||||
assert result.success is True
|
||||
assert result.message_id == "plain-msg"
|
||||
assert calls == [
|
||||
("/send-richlink", {"spaceId": "+155****4567", "url": _URL}),
|
||||
("/send", {"spaceId": "+155****4567", "text": _URL}),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_standalone_url_only_send_routes_to_richlink_endpoint(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
|
||||
monkeypatch.setenv("PHOTON_SIDECAR_TOKEN", "tok")
|
||||
posted: List[Tuple[str, Dict[str, Any]]] = []
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
|
||||
@staticmethod
|
||||
def json() -> Dict[str, Any]:
|
||||
return {"ok": True, "messageId": "m-9"}
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, *a, **k):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
async def post(self, url: str, json: Dict[str, Any], headers=None):
|
||||
posted.append((url, json))
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _FakeClient)
|
||||
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
result = await photon_adapter._standalone_send(cfg, "+155****4567", _URL)
|
||||
|
||||
assert result.get("success") is True
|
||||
assert posted == [
|
||||
(
|
||||
"http://127.0.0.1:8789/send-richlink",
|
||||
{"spaceId": "+155****4567", "url": _URL},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inbound_richlink_dispatches_url_text(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture_inbound(adapter, monkeypatch)
|
||||
event = _dm_event({"type": "richlink", "url": _URL})
|
||||
|
||||
await adapter._dispatch_inbound(event)
|
||||
|
||||
assert len(captured) == 1
|
||||
assert captured[0].text == _URL
|
||||
assert captured[0].message_type == MessageType.TEXT
|
||||
assert captured[0].raw_message["content"] == {"type": "richlink", "url": _URL}
|
||||
|
||||
|
||||
_PNG_1X1_B64 = (
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhf"
|
||||
"DwAChwGA60e6kgAAAABJRU5ErkJggg=="
|
||||
)
|
||||
|
||||
|
||||
def _preview_attachment(
|
||||
name: str = "preview.pluginPayloadAttachment",
|
||||
mime_type: str = "image/png",
|
||||
) -> Dict[str, Any]:
|
||||
raw = base64.b64decode(_PNG_1X1_B64)
|
||||
return {
|
||||
"type": "attachment",
|
||||
"name": name,
|
||||
"mimeType": mime_type,
|
||||
"size": len(raw),
|
||||
"data": _PNG_1X1_B64,
|
||||
"encoding": "base64",
|
||||
}
|
||||
|
||||
|
||||
def _preview_attachment_by_id(
|
||||
attachment_id: str = "doc_123.pluginPayloadAttachment",
|
||||
) -> Dict[str, Any]:
|
||||
payload = _preview_attachment(name="")
|
||||
payload["id"] = attachment_id
|
||||
payload["name"] = None
|
||||
return payload
|
||||
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Sidecar runtime-record persistence tests (issue #69960).
|
||||
|
||||
The sidecar token is generated at spawn and used to exist only in the
|
||||
gateway process memory + sidecar child env — so cron/`hermes send`
|
||||
standalone sends structurally could not authenticate. The adapter now
|
||||
persists ``<hermes-home>/runtime/photon-sidecar.json`` after the sidecar
|
||||
passes its /healthz readiness check, deletes it on stop/failed-start, and
|
||||
``_standalone_send`` falls back to it when PHOTON_SIDECAR_TOKEN is unset.
|
||||
No Node, no ports, no network.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from plugins.platforms.photon import adapter as photon_adapter
|
||||
from plugins.platforms.photon.adapter import PhotonAdapter
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def record_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
path = tmp_path / "runtime" / "photon-sidecar.json"
|
||||
monkeypatch.setattr(photon_adapter, "_runtime_record_path", lambda: path)
|
||||
return path
|
||||
|
||||
|
||||
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
|
||||
monkeypatch.delenv("PHOTON_SIDECAR_TOKEN", raising=False)
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
return PhotonAdapter(cfg)
|
||||
|
||||
|
||||
# -- record helpers ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_write_read_delete_roundtrip(record_path: Path) -> None:
|
||||
photon_adapter._write_runtime_record(8789, "tok123", 4242)
|
||||
|
||||
assert record_path.exists()
|
||||
data = json.loads(record_path.read_text(encoding="utf-8"))
|
||||
assert data == {"port": 8789, "token": "tok123", "pid": 4242}
|
||||
assert photon_adapter._read_runtime_record() == data
|
||||
|
||||
photon_adapter._delete_runtime_record()
|
||||
assert not record_path.exists()
|
||||
# Idempotent: deleting a missing record must not raise.
|
||||
photon_adapter._delete_runtime_record()
|
||||
assert photon_adapter._read_runtime_record() is None
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX permission bits")
|
||||
def test_record_written_with_0600(record_path: Path) -> None:
|
||||
photon_adapter._write_runtime_record(8789, "secret", 1)
|
||||
mode = stat.S_IMODE(record_path.stat().st_mode)
|
||||
assert mode == 0o600
|
||||
|
||||
|
||||
def test_read_tolerates_corrupt_record(record_path: Path) -> None:
|
||||
record_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
record_path.write_text("{not json", encoding="utf-8")
|
||||
assert photon_adapter._read_runtime_record() is None
|
||||
|
||||
|
||||
# -- lifecycle: written after healthz success, removed on stop/failure -------
|
||||
|
||||
|
||||
class _HealthzClient:
|
||||
"""Fake httpx.AsyncClient whose /healthz response is injectable."""
|
||||
|
||||
status_code = 200
|
||||
|
||||
def __init__(self, *a: Any, **k: Any) -> None:
|
||||
pass
|
||||
|
||||
async def __aenter__(self) -> "_HealthzClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a: Any) -> bool:
|
||||
return False
|
||||
|
||||
async def post(self, *a: Any, **k: Any) -> Any:
|
||||
cls = type(self)
|
||||
|
||||
class _Resp:
|
||||
status_code = cls.status_code
|
||||
|
||||
return _Resp()
|
||||
|
||||
|
||||
class _FakeProc:
|
||||
pid = 4242
|
||||
stdin = None
|
||||
returncode: int | None = None
|
||||
|
||||
def poll(self) -> int | None:
|
||||
return None
|
||||
|
||||
def wait(self, timeout: float | None = None) -> int:
|
||||
return 0
|
||||
|
||||
def terminate(self) -> None:
|
||||
pass
|
||||
|
||||
def kill(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _patch_spawn(
|
||||
monkeypatch: pytest.MonkeyPatch, adapter: PhotonAdapter, tmp_path: Path
|
||||
) -> None:
|
||||
"""Stub everything _start_sidecar touches before the healthz loop."""
|
||||
sidecar_dir = tmp_path / "sidecar"
|
||||
# sidecar_deps_installed() checks the dependency's own directory, not just
|
||||
# node_modules/ (9cf2046081) — mirror a real completed install.
|
||||
(sidecar_dir / "node_modules" / "spectrum-ts").mkdir(parents=True)
|
||||
monkeypatch.setattr(photon_adapter, "_SIDECAR_DIR", sidecar_dir)
|
||||
monkeypatch.setattr(photon_adapter, "_sidecar_deps_stale", lambda: False)
|
||||
|
||||
async def _no_reap(self: PhotonAdapter) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(PhotonAdapter, "_reap_stale_sidecar", _no_reap)
|
||||
monkeypatch.setattr(
|
||||
photon_adapter.subprocess,
|
||||
"run",
|
||||
lambda *a, **k: type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
photon_adapter.subprocess, "Popen", lambda *a, **k: _FakeProc()
|
||||
)
|
||||
|
||||
async def _no_supervise(self: PhotonAdapter, proc: Any) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(PhotonAdapter, "_supervise_sidecar", _no_supervise)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_written_after_healthz_success(
|
||||
monkeypatch: pytest.MonkeyPatch, record_path: Path, tmp_path: Path
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
_patch_spawn(monkeypatch, adapter, tmp_path)
|
||||
_HealthzClient.status_code = 200
|
||||
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _HealthzClient)
|
||||
|
||||
await adapter._start_sidecar()
|
||||
|
||||
data = json.loads(record_path.read_text(encoding="utf-8"))
|
||||
assert data["port"] == adapter._sidecar_port
|
||||
assert data["token"] == adapter._sidecar_token
|
||||
assert data["pid"] == 4242
|
||||
|
||||
# Cleanup so the fake supervisor task doesn't leak between tests.
|
||||
if adapter._sidecar_supervisor_task is not None:
|
||||
adapter._sidecar_supervisor_task.cancel()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_without_proc_still_clears_record(
|
||||
monkeypatch: pytest.MonkeyPatch, record_path: Path
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
photon_adapter._write_runtime_record(8789, "tok", 4242)
|
||||
adapter._sidecar_proc = None
|
||||
|
||||
await adapter._stop_sidecar()
|
||||
|
||||
assert not record_path.exists()
|
||||
|
||||
|
||||
# -- _standalone_send fallback ------------------------------------------------
|
||||
|
||||
|
||||
class _SendClient:
|
||||
"""Fake httpx.AsyncClient capturing /send calls."""
|
||||
|
||||
calls: list = []
|
||||
|
||||
def __init__(self, *a: Any, **k: Any) -> None:
|
||||
pass
|
||||
|
||||
async def __aenter__(self) -> "_SendClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a: Any) -> bool:
|
||||
return False
|
||||
|
||||
async def post(self, url: str, json: Any = None, headers: Any = None) -> Any:
|
||||
type(self).calls.append((url, json, headers))
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
text = ""
|
||||
|
||||
@staticmethod
|
||||
def json() -> dict:
|
||||
return {"ok": True, "messageId": "m1"}
|
||||
|
||||
return _Resp()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_standalone_send_consumes_record_when_env_missing(
|
||||
monkeypatch: pytest.MonkeyPatch, record_path: Path
|
||||
) -> None:
|
||||
monkeypatch.delenv("PHOTON_SIDECAR_TOKEN", raising=False)
|
||||
monkeypatch.delenv("PHOTON_SIDECAR_PORT", raising=False)
|
||||
photon_adapter._write_runtime_record(9111, "record-token", os.getpid())
|
||||
_SendClient.calls = []
|
||||
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _SendClient)
|
||||
|
||||
result = await photon_adapter._standalone_send(
|
||||
PlatformConfig(enabled=True, token="", extra={}), "+15551234567", "hi"
|
||||
)
|
||||
|
||||
assert result == {"success": True, "message_id": "m1"}
|
||||
url, _body, headers = _SendClient.calls[0]
|
||||
assert ":9111/" in url
|
||||
assert headers["X-Hermes-Sidecar-Token"] == "record-token"
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Tests for `hermes photon setup`'s access auto-configuration.
|
||||
|
||||
`_autoconfigure_access` allowlists the operator and points the cron home
|
||||
channel at their DM, writing to the per-test ~/.hermes/.env (the hermetic
|
||||
HERMES_HOME fixture isolates this). It must fill only unset keys so a re-run
|
||||
never clobbers a hand-tuned allowlist.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.config import get_env_value, save_env_value
|
||||
from plugins.platforms.photon.adapter import _env_enablement
|
||||
from plugins.platforms.photon import cli
|
||||
|
||||
|
||||
def test_autoconfigure_access_fills_unset(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("PHOTON_ALLOWED_USERS", raising=False)
|
||||
monkeypatch.delenv("PHOTON_HOME_CHANNEL", raising=False)
|
||||
|
||||
cli._autoconfigure_access("+15551234567")
|
||||
|
||||
assert get_env_value("PHOTON_ALLOWED_USERS") == "+15551234567"
|
||||
assert get_env_value("PHOTON_HOME_CHANNEL") == "+15551234567"
|
||||
|
||||
|
||||
def test_env_enablement_seeds_home_channel(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "project_123")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "secret_123")
|
||||
monkeypatch.setenv("PHOTON_HOME_CHANNEL", "+15551234567")
|
||||
monkeypatch.setenv("PHOTON_HOME_CHANNEL_NAME", "Primary DM")
|
||||
|
||||
seed = _env_enablement()
|
||||
|
||||
assert seed is not None
|
||||
assert seed["home_channel"] == {
|
||||
"chat_id": "+15551234567",
|
||||
"name": "Primary DM",
|
||||
}
|
||||
|
||||
|
||||
def test_setup_hint_uses_gateway_service_command(monkeypatch: pytest.MonkeyPatch, capsys) -> None:
|
||||
monkeypatch.setattr(cli.photon_auth, "load_photon_token", lambda: "token")
|
||||
# Token validation (added for #72763) would otherwise hit the network.
|
||||
monkeypatch.setattr(cli.photon_auth, "check_photon_token_valid", lambda token: True)
|
||||
# The dashboard id *is* the Spectrum project id (ids unified), so setup no
|
||||
# longer enables Spectrum or fetches a separate spectrumProjectId — it
|
||||
# reuses this id directly.
|
||||
monkeypatch.setattr(cli.photon_auth, "load_dashboard_project_id", lambda: "dashboard")
|
||||
# No existing credentials — first-time setup path.
|
||||
monkeypatch.setattr(
|
||||
cli.photon_auth, "load_project_credentials", lambda: (None, None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cli.photon_auth,
|
||||
"regenerate_project_secret",
|
||||
lambda token, dashboard_id: "secret_123",
|
||||
)
|
||||
monkeypatch.setattr(cli.photon_auth, "store_project_credentials", lambda **kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
cli.photon_auth,
|
||||
"register_user_if_absent",
|
||||
lambda *args, **kwargs: ({"id": "user_123", "phoneNumber": "+155****4567"}, True),
|
||||
)
|
||||
monkeypatch.setattr(cli.photon_auth, "user_assigned_line", lambda user: "+155****4321")
|
||||
monkeypatch.setattr(cli.photon_auth, "store_user_numbers", lambda **kwargs: None)
|
||||
monkeypatch.setattr(cli, "_install_sidecar", lambda: 0)
|
||||
|
||||
rc = cli._cmd_setup(
|
||||
argparse.Namespace(
|
||||
project_name=None,
|
||||
phone="+155****4567",
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
email=None,
|
||||
no_browser=True,
|
||||
skip_sidecar_install=False,
|
||||
)
|
||||
)
|
||||
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "Start the gateway: hermes gateway start" in out
|
||||
assert "--platform photon" not in out
|
||||
assert "new secret saved" in out
|
||||
assert "restart it so the sidecar" in out
|
||||
|
||||
|
||||
def test_setup_reuses_valid_existing_secret(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys,
|
||||
) -> None:
|
||||
"""Re-running setup with a valid existing secret must NOT regenerate it."""
|
||||
regenerate_called = False
|
||||
|
||||
def _fake_regenerate(token, dashboard_id):
|
||||
nonlocal regenerate_called
|
||||
regenerate_called = True
|
||||
return "new_secret"
|
||||
|
||||
monkeypatch.setattr(cli.photon_auth, "load_photon_token", lambda: "token")
|
||||
# Token validation (added for #72763) would otherwise hit the network.
|
||||
monkeypatch.setattr(cli.photon_auth, "check_photon_token_valid", lambda token: True)
|
||||
monkeypatch.setattr(cli.photon_auth, "load_dashboard_project_id", lambda: "dashboard")
|
||||
monkeypatch.setattr(
|
||||
cli.photon_auth,
|
||||
"load_project_credentials",
|
||||
lambda: ("dashboard", "existing_secret"),
|
||||
)
|
||||
# list_users succeeds — existing secret is valid.
|
||||
monkeypatch.setattr(
|
||||
cli.photon_auth, "list_users", lambda pid, secret: [],
|
||||
)
|
||||
monkeypatch.setattr(cli.photon_auth, "regenerate_project_secret", _fake_regenerate)
|
||||
monkeypatch.setattr(cli.photon_auth, "store_project_credentials", lambda **kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
cli.photon_auth,
|
||||
"register_user_if_absent",
|
||||
lambda *args, **kwargs: ({"id": "user_123", "phoneNumber": "+155****4567"}, True),
|
||||
)
|
||||
monkeypatch.setattr(cli.photon_auth, "user_assigned_line", lambda user: "+155****4321")
|
||||
monkeypatch.setattr(cli.photon_auth, "store_user_numbers", lambda **kwargs: None)
|
||||
monkeypatch.setattr(cli, "_install_sidecar", lambda: 0)
|
||||
|
||||
rc = cli._cmd_setup(
|
||||
argparse.Namespace(
|
||||
project_name=None,
|
||||
phone="+155****4567",
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
email=None,
|
||||
no_browser=True,
|
||||
skip_sidecar_install=False,
|
||||
)
|
||||
)
|
||||
|
||||
assert rc == 0
|
||||
assert not regenerate_called, "regenerate_project_secret must not be called when existing creds are valid"
|
||||
out = capsys.readouterr().out
|
||||
assert "existing credentials valid" in out
|
||||
assert "restart" not in out.lower()
|
||||
|
||||
|
||||
def test_setup_regenerates_when_existing_secret_invalid(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys,
|
||||
) -> None:
|
||||
"""When existing credentials are invalid, setup must regenerate."""
|
||||
monkeypatch.setattr(cli.photon_auth, "load_photon_token", lambda: "token")
|
||||
# Token validation (added for #72763) would otherwise hit the network.
|
||||
monkeypatch.setattr(cli.photon_auth, "check_photon_token_valid", lambda token: True)
|
||||
monkeypatch.setattr(cli.photon_auth, "load_dashboard_project_id", lambda: "dashboard")
|
||||
monkeypatch.setattr(
|
||||
cli.photon_auth,
|
||||
"load_project_credentials",
|
||||
lambda: ("dashboard", "stale_secret"),
|
||||
)
|
||||
# list_users fails — existing secret is invalid.
|
||||
monkeypatch.setattr(
|
||||
cli.photon_auth,
|
||||
"list_users",
|
||||
lambda pid, secret: (_ for _ in ()).throw(RuntimeError("auth failed")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cli.photon_auth,
|
||||
"regenerate_project_secret",
|
||||
lambda token, dashboard_id: "new_secret",
|
||||
)
|
||||
monkeypatch.setattr(cli.photon_auth, "store_project_credentials", lambda **kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
cli.photon_auth,
|
||||
"register_user_if_absent",
|
||||
lambda *args, **kwargs: ({"id": "user_123", "phoneNumber": "+155****4567"}, True),
|
||||
)
|
||||
monkeypatch.setattr(cli.photon_auth, "user_assigned_line", lambda user: "+155****4321")
|
||||
monkeypatch.setattr(cli.photon_auth, "store_user_numbers", lambda **kwargs: None)
|
||||
monkeypatch.setattr(cli, "_install_sidecar", lambda: 0)
|
||||
|
||||
rc = cli._cmd_setup(
|
||||
argparse.Namespace(
|
||||
project_name=None,
|
||||
phone="+155****4567",
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
email=None,
|
||||
no_browser=True,
|
||||
skip_sidecar_install=False,
|
||||
)
|
||||
)
|
||||
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "new secret saved" in out
|
||||
assert "restart it so the sidecar" in out
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Regression tests for the Photon sidecar stale-dependency self-heal.
|
||||
|
||||
A `hermes update` that bumps the spectrum-ts pin rewrites the sidecar's
|
||||
``package-lock.json`` but never reinstalls ``node_modules``, so the sidecar
|
||||
spawns against stale deps and dies on every reconnect. ``_sidecar_deps_stale``
|
||||
detects that skew (lockfile newer than npm's install marker) so
|
||||
``_start_sidecar`` can reinstall before spawning.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import plugins.platforms.photon.adapter as photon_adapter
|
||||
|
||||
|
||||
def _seed(sidecar: Path, *, lock_mtime: float, marker_mtime: float | None) -> None:
|
||||
"""Create a fake sidecar dir with a lockfile and (optionally) npm's marker."""
|
||||
(sidecar / "node_modules").mkdir(parents=True)
|
||||
lock = sidecar / "package-lock.json"
|
||||
lock.write_text("{}", encoding="utf-8")
|
||||
os.utime(lock, (lock_mtime, lock_mtime))
|
||||
if marker_mtime is not None:
|
||||
marker = sidecar / "node_modules" / ".package-lock.json"
|
||||
marker.write_text("{}", encoding="utf-8")
|
||||
os.utime(marker, (marker_mtime, marker_mtime))
|
||||
|
||||
|
||||
def test_stale_when_lockfile_newer_than_marker(tmp_path, monkeypatch) -> None:
|
||||
"""The update-rewrites-lockfile-but-skips-install case must reinstall."""
|
||||
sidecar = tmp_path / "sidecar"
|
||||
_seed(sidecar, lock_mtime=2000.0, marker_mtime=1000.0)
|
||||
monkeypatch.setattr(photon_adapter, "_SIDECAR_DIR", sidecar)
|
||||
assert photon_adapter._sidecar_deps_stale() is True
|
||||
|
||||
|
||||
def test_fresh_when_marker_newer_than_lockfile(tmp_path, monkeypatch) -> None:
|
||||
"""A normal install (marker at/after lockfile) must NOT trigger a reinstall."""
|
||||
sidecar = tmp_path / "sidecar"
|
||||
_seed(sidecar, lock_mtime=1000.0, marker_mtime=2000.0)
|
||||
monkeypatch.setattr(photon_adapter, "_SIDECAR_DIR", sidecar)
|
||||
assert photon_adapter._sidecar_deps_stale() is False
|
||||
|
||||
|
||||
def test_not_stale_when_marker_missing(tmp_path, monkeypatch) -> None:
|
||||
"""No marker (first run / unreadable) must fail safe to False, never block start."""
|
||||
sidecar = tmp_path / "sidecar"
|
||||
_seed(sidecar, lock_mtime=2000.0, marker_mtime=None)
|
||||
monkeypatch.setattr(photon_adapter, "_SIDECAR_DIR", sidecar)
|
||||
assert photon_adapter._sidecar_deps_stale() is False
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Sidecar lifecycle tests: orphan reaping and parent-death wiring.
|
||||
|
||||
A hard gateway exit used to leave the detached Node sidecar squatting the
|
||||
loopback port with a token the next gateway run doesn't know — every
|
||||
replacement spawn then died on EADDRINUSE. These tests cover the startup
|
||||
reaper (`_reap_stale_sidecar`) and the stdin-pipe lifetime binding, without
|
||||
spawning Node or binding ports.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from plugins.platforms.photon import adapter as photon_adapter
|
||||
from plugins.platforms.photon.adapter import PhotonAdapter
|
||||
|
||||
|
||||
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
return PhotonAdapter(cfg)
|
||||
|
||||
|
||||
class _ProbeClient:
|
||||
"""Fake httpx.AsyncClient whose /healthz probe behavior is injectable."""
|
||||
|
||||
connects = True
|
||||
|
||||
def __init__(self, *a: Any, **k: Any) -> None:
|
||||
pass
|
||||
|
||||
async def __aenter__(self) -> "_ProbeClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a: Any) -> bool:
|
||||
return False
|
||||
|
||||
async def post(self, *a: Any, **k: Any) -> Any:
|
||||
if not self.connects:
|
||||
raise photon_adapter.httpx.ConnectError("connection refused")
|
||||
|
||||
class _Resp:
|
||||
status_code = 401 # orphan with a different token
|
||||
|
||||
return _Resp()
|
||||
|
||||
|
||||
def _capture_kills(monkeypatch: pytest.MonkeyPatch) -> List[Tuple[int, int]]:
|
||||
kills: List[Tuple[int, int]] = []
|
||||
|
||||
def _fake_kill(pid: int, sig: int) -> None:
|
||||
kills.append((pid, sig))
|
||||
|
||||
monkeypatch.setattr(photon_adapter.os, "kill", _fake_kill)
|
||||
return kills
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reap_noop_when_port_free(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
|
||||
class _Refused(_ProbeClient):
|
||||
connects = False
|
||||
|
||||
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _Refused)
|
||||
kills = _capture_kills(monkeypatch)
|
||||
|
||||
await adapter._reap_stale_sidecar()
|
||||
|
||||
assert kills == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_sidecar_spawns_with_stdin_pipe(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
"""The spawn must hold a stdin pipe and enable the sidecar's EOF watch."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
|
||||
async def _no_reap() -> None:
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(adapter, "_reap_stale_sidecar", _no_reap)
|
||||
(tmp_path / "node_modules" / "spectrum-ts").mkdir(parents=True)
|
||||
monkeypatch.setattr(photon_adapter, "_SIDECAR_DIR", tmp_path)
|
||||
|
||||
spawned: Dict[str, Any] = {}
|
||||
hidden_flags = 0x08000000
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli._subprocess_compat.windows_hide_flags",
|
||||
lambda: hidden_flags,
|
||||
)
|
||||
|
||||
class _PatchResult:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
|
||||
def _fake_run(cmd: List[str], **kwargs: Any) -> _PatchResult:
|
||||
spawned["patch_cmd"] = cmd
|
||||
spawned["patch_kwargs"] = kwargs
|
||||
return _PatchResult()
|
||||
|
||||
monkeypatch.setattr(photon_adapter.subprocess, "run", _fake_run)
|
||||
|
||||
class _FakeProc:
|
||||
pid = 999
|
||||
stdout = None
|
||||
stdin = None
|
||||
|
||||
@staticmethod
|
||||
def poll() -> None:
|
||||
return None
|
||||
|
||||
def _fake_popen(cmd: List[str], **kwargs: Any) -> _FakeProc:
|
||||
spawned["cmd"] = cmd
|
||||
spawned["kwargs"] = kwargs
|
||||
return _FakeProc()
|
||||
|
||||
monkeypatch.setattr(photon_adapter.subprocess, "Popen", _fake_popen)
|
||||
|
||||
class _HealthyClient(_ProbeClient):
|
||||
async def post(self, *a: Any, **k: Any) -> Any:
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _HealthyClient)
|
||||
|
||||
await adapter._start_sidecar()
|
||||
|
||||
kwargs = spawned["kwargs"]
|
||||
assert kwargs["stdin"] is subprocess.PIPE
|
||||
assert kwargs["env"]["PHOTON_SIDECAR_WATCH_STDIN"] == "1"
|
||||
assert spawned["patch_kwargs"]["creationflags"] == hidden_flags
|
||||
assert kwargs["creationflags"] == hidden_flags
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spectrum_patch_runs_off_the_event_loop(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The node patch run must not block the shared gateway event loop.
|
||||
|
||||
``_start_sidecar`` spawns the Spectrum patch script and *waits* for it
|
||||
(``timeout=10``). Run inline it holds the loop for that whole window, so
|
||||
every other platform's traffic stalls — and ``_start_sidecar`` runs on
|
||||
every reconnect (``connect(is_reconnect=True)``), not just startup, so the
|
||||
stall recurs on a live gateway. The dep reinstall a few lines above already
|
||||
hops to a thread for exactly this reason; the patch run must too.
|
||||
"""
|
||||
import threading
|
||||
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
main_thread = threading.current_thread()
|
||||
seen: Dict[str, Any] = {}
|
||||
|
||||
# node_modules present + deps fresh, so we reach the patch run.
|
||||
monkeypatch.setattr(photon_adapter.Path, "exists", lambda self: True)
|
||||
monkeypatch.setattr(photon_adapter, "_sidecar_deps_stale", lambda: False)
|
||||
|
||||
async def _no_reap() -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(adapter, "_reap_stale_sidecar", _no_reap)
|
||||
|
||||
def _fake_run(*a: Any, **k: Any) -> Any:
|
||||
seen["thread"] = threading.current_thread()
|
||||
|
||||
class _Done:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
|
||||
return _Done()
|
||||
|
||||
monkeypatch.setattr(photon_adapter.subprocess, "run", _fake_run)
|
||||
|
||||
class _FakeProc:
|
||||
pid = 4242
|
||||
stdin = None
|
||||
stdout = None
|
||||
|
||||
def poll(self) -> int:
|
||||
# Report "exited" so the readiness health-poll loop bails out
|
||||
# immediately instead of spinning for its full 15s deadline —
|
||||
# the assertion below only cares where the patch run executed.
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(
|
||||
photon_adapter.subprocess, "Popen", lambda *a, **k: _FakeProc()
|
||||
)
|
||||
|
||||
try:
|
||||
await adapter._start_sidecar()
|
||||
except Exception:
|
||||
# Readiness/handshake past the patch run may fail under the fakes —
|
||||
# irrelevant here; we only assert where the patch run executed.
|
||||
pass
|
||||
|
||||
assert seen.get("thread") is not None, "patch run never executed"
|
||||
assert seen["thread"] is not main_thread, (
|
||||
"Spectrum patch subprocess ran on the event-loop thread; it must be "
|
||||
"dispatched via asyncio.to_thread so a 10s node spawn can't freeze "
|
||||
"every other platform on the gateway loop"
|
||||
)
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Tests for the Photon sidecar directory resolver (NS-606).
|
||||
|
||||
Hosted/managed images keep the plugin tree under an immutable
|
||||
``/opt/hermes``; ``resolve_sidecar_dir`` must run in place when the deps are
|
||||
baked and current, and mirror the sidecar to the writable ``HERMES_HOME``
|
||||
volume when a runtime install is unavoidable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import plugins.platforms.photon.sidecar_paths as sidecar_paths
|
||||
|
||||
|
||||
def _seed_source(source: Path, *, with_node_modules: bool = False) -> None:
|
||||
source.mkdir(parents=True, exist_ok=True)
|
||||
for name in sidecar_paths._MIRROR_FILES:
|
||||
(source / name).write_text(f"// {name}\n", encoding="utf-8")
|
||||
if with_node_modules:
|
||||
(source / "node_modules").mkdir()
|
||||
(source / "node_modules" / ".package-lock.json").write_text(
|
||||
"{}", encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def _freeze_writability(monkeypatch, *, writable: bool) -> None:
|
||||
monkeypatch.setattr(sidecar_paths, "_dir_writable", lambda _p: writable)
|
||||
|
||||
|
||||
def test_env_override_wins(tmp_path, monkeypatch) -> None:
|
||||
override = tmp_path / "custom"
|
||||
monkeypatch.setenv("PHOTON_SIDECAR_DIR", str(override))
|
||||
assert sidecar_paths.resolve_sidecar_dir(tmp_path / "src") == override
|
||||
|
||||
|
||||
def test_writable_source_runs_in_place(tmp_path, monkeypatch) -> None:
|
||||
"""Dev installs: writable tree keeps today's behavior exactly."""
|
||||
monkeypatch.delenv("PHOTON_SIDECAR_DIR", raising=False)
|
||||
source = tmp_path / "src"
|
||||
_seed_source(source)
|
||||
_freeze_writability(monkeypatch, writable=True)
|
||||
assert sidecar_paths.resolve_sidecar_dir(source) == source
|
||||
|
||||
|
||||
def test_readonly_source_with_baked_fresh_deps_runs_in_place(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""Managed-image happy path: deps baked at build time, no mirror needed."""
|
||||
monkeypatch.delenv("PHOTON_SIDECAR_DIR", raising=False)
|
||||
source = tmp_path / "src"
|
||||
_seed_source(source, with_node_modules=True)
|
||||
# Marker newer than lockfile == fresh install.
|
||||
lock = source / "package-lock.json"
|
||||
marker = source / "node_modules" / ".package-lock.json"
|
||||
os.utime(lock, (1000.0, 1000.0))
|
||||
os.utime(marker, (2000.0, 2000.0))
|
||||
_freeze_writability(monkeypatch, writable=False)
|
||||
assert sidecar_paths.resolve_sidecar_dir(source) == source
|
||||
|
||||
|
||||
def test_mirror_refresh_updates_changed_files_and_keeps_node_modules(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""Image update changes index.mjs → re-copied; installed deps survive."""
|
||||
monkeypatch.delenv("PHOTON_SIDECAR_DIR", raising=False)
|
||||
home = tmp_path / "home"
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
source = tmp_path / "src"
|
||||
_seed_source(source)
|
||||
_freeze_writability(monkeypatch, writable=False)
|
||||
|
||||
mirror = sidecar_paths.resolve_sidecar_dir(source)
|
||||
# Simulate a completed npm install in the mirror.
|
||||
(mirror / "node_modules").mkdir()
|
||||
(mirror / "node_modules" / "installed.txt").write_text("x", encoding="utf-8")
|
||||
|
||||
# Image update rewrites a source file.
|
||||
(source / "index.mjs").write_text("// index.mjs v2\n", encoding="utf-8")
|
||||
|
||||
resolved = sidecar_paths.resolve_sidecar_dir(source)
|
||||
|
||||
assert resolved == mirror
|
||||
assert (mirror / "index.mjs").read_text(encoding="utf-8") == "// index.mjs v2\n"
|
||||
assert (mirror / "node_modules" / "installed.txt").exists()
|
||||
|
||||
|
||||
def test_dir_writable_probe(tmp_path) -> None:
|
||||
assert sidecar_paths.dir_writable(tmp_path) is True
|
||||
ro = tmp_path / "ro"
|
||||
ro.mkdir()
|
||||
ro.chmod(0o555)
|
||||
try:
|
||||
if os.geteuid() == 0: # pragma: no cover - root ignores perms
|
||||
pytest.skip("root bypasses directory permissions")
|
||||
assert sidecar_paths.dir_writable(ro) is False
|
||||
finally:
|
||||
ro.chmod(0o755)
|
||||
|
||||
|
||||
def test_adapter_import_does_not_resolve_sidecar_dir(monkeypatch) -> None:
|
||||
"""Importing the adapter must not probe the filesystem or mirror files.
|
||||
|
||||
resolve_sidecar_dir() touch/unlink-probes the source tree and may copy
|
||||
files to HERMES_HOME; the adapter and CLI resolve lazily on first use so
|
||||
a bare import (plugin discovery, `hermes --help`, test collection) has
|
||||
no filesystem side effects.
|
||||
"""
|
||||
import importlib
|
||||
|
||||
from plugins.platforms.photon import adapter as photon_adapter
|
||||
from plugins.platforms.photon import cli as photon_cli
|
||||
|
||||
def _boom(*args, **kwargs): # pragma: no cover - failure path
|
||||
raise AssertionError("resolve_sidecar_dir called at import time")
|
||||
|
||||
monkeypatch.setattr(sidecar_paths, "resolve_sidecar_dir", _boom)
|
||||
try:
|
||||
importlib.reload(photon_adapter)
|
||||
importlib.reload(photon_cli)
|
||||
# Nothing resolved yet.
|
||||
assert photon_adapter._SIDECAR_DIR is None
|
||||
assert photon_cli._SIDECAR_DIR is None
|
||||
# First real use resolves (and would call resolve_sidecar_dir).
|
||||
with pytest.raises(AssertionError, match="import time"):
|
||||
photon_adapter._sidecar_dir()
|
||||
# A monkeypatched _SIDECAR_DIR (the pattern existing tests use) is
|
||||
# honored without touching the resolver.
|
||||
monkeypatch.setattr(photon_adapter, "_SIDECAR_DIR", Path("/tmp/x"))
|
||||
assert photon_adapter._sidecar_dir() == Path("/tmp/x")
|
||||
assert photon_adapter._npm_error_log() == Path("/tmp/x/.photon-npm-error.log")
|
||||
finally:
|
||||
# Restore real bindings for any later test importing these modules.
|
||||
monkeypatch.undo()
|
||||
importlib.reload(photon_adapter)
|
||||
importlib.reload(photon_cli)
|
||||
@@ -0,0 +1,277 @@
|
||||
"""Regression tests for Hermes' Spectrum mixed text+attachment workaround."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import textwrap
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_PATCHER = Path("plugins/platforms/photon/sidecar/patch-spectrum-mixed-attachments.mjs")
|
||||
|
||||
|
||||
def _sidecar_env(port: int) -> dict[str, str]:
|
||||
return {
|
||||
**os.environ,
|
||||
"PHOTON_PROJECT_ID": "test-project",
|
||||
"PHOTON_PROJECT_SECRET": "test-secret",
|
||||
"PHOTON_SIDECAR_PORT": str(port),
|
||||
"PHOTON_SIDECAR_TOKEN": "test-token",
|
||||
}
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return sock.getsockname()[1]
|
||||
|
||||
|
||||
def _write_sidecar_fixture(tmp_path: Path, *, sdk_available: bool) -> Path:
|
||||
sidecar = tmp_path / "sidecar"
|
||||
sidecar.mkdir()
|
||||
shutil.copyfile("plugins/platforms/photon/sidecar/index.mjs", sidecar / "index.mjs")
|
||||
# index.mjs imports sibling helper modules — copy every non-patch .mjs so
|
||||
# the fixture keeps working as helpers are extracted from index.mjs.
|
||||
for helper in Path("plugins/platforms/photon/sidecar").glob("*.mjs"):
|
||||
if helper.name in ("index.mjs", "patch-spectrum-mixed-attachments.mjs"):
|
||||
continue
|
||||
shutil.copyfile(helper, sidecar / helper.name)
|
||||
(sidecar / "patch-spectrum-mixed-attachments.mjs").write_text(
|
||||
"export function patchSpectrumTs() { throw new Error('forced patch failure'); }\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
if not sdk_available:
|
||||
return sidecar
|
||||
|
||||
package = sidecar / "node_modules" / "spectrum-ts"
|
||||
(package / "providers").mkdir(parents=True)
|
||||
(package / "package.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"name": "spectrum-ts",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.js",
|
||||
"./providers/imessage": "./providers/imessage.js",
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(package / "index.js").write_text(
|
||||
textwrap.dedent(
|
||||
"""
|
||||
export async function Spectrum() {
|
||||
return {
|
||||
messages: { [Symbol.asyncIterator]() { return { next: () => new Promise(() => {}) }; } },
|
||||
stop: async () => undefined,
|
||||
};
|
||||
}
|
||||
export const attachment = value => value;
|
||||
export const voice = value => value;
|
||||
export const text = value => value;
|
||||
export const markdown = value => value;
|
||||
export const typing = value => value;
|
||||
"""
|
||||
).lstrip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(package / "providers" / "imessage.js").write_text(
|
||||
"export function imessage() { return {}; }\nimessage.config = () => ({});\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return sidecar
|
||||
|
||||
|
||||
def test_sidecar_patch_failure_still_reaches_health_endpoint(tmp_path: Path) -> None:
|
||||
"""The compatibility patch is optional when the SDK itself remains usable."""
|
||||
sidecar = _write_sidecar_fixture(tmp_path, sdk_available=True)
|
||||
port = _free_port()
|
||||
proc = subprocess.Popen(
|
||||
["node", "index.mjs"],
|
||||
cwd=sidecar,
|
||||
env=_sidecar_env(port),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
request = urllib.request.Request(
|
||||
f"http://127.0.0.1:{port}/healthz",
|
||||
data=b"{}",
|
||||
headers={"X-Hermes-Sidecar-Token": "test-token"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
deadline = time.monotonic() + 5
|
||||
while True:
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=0.5) as response:
|
||||
payload = json.load(response)
|
||||
break
|
||||
except OSError:
|
||||
if proc.poll() is not None or time.monotonic() >= deadline:
|
||||
raise
|
||||
time.sleep(0.05)
|
||||
|
||||
assert payload["ok"] is True
|
||||
assert proc.poll() is None
|
||||
finally:
|
||||
proc.terminate()
|
||||
_, stderr = proc.communicate(timeout=5)
|
||||
|
||||
assert "forced patch failure" in stderr
|
||||
|
||||
|
||||
def _tabify(src: str) -> str:
|
||||
"""Convert the fixture's two-space indentation to the tab indentation that
|
||||
spectrum-ts ships in `@spectrum-ts/imessage/dist`, so the patch anchors
|
||||
(which match tabs) apply exactly as they do against a real install."""
|
||||
out = []
|
||||
for line in src.split("\n"):
|
||||
stripped = line.lstrip(" ")
|
||||
indent = len(line) - len(stripped)
|
||||
out.append("\t" * (indent // 2) + " " * (indent % 2) + stripped)
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
# A faithful, *executable* slice of spectrum-ts 8.x's iMessage inbound mapper:
|
||||
# the two functions the patch rewrites (`rebuildFromAppleMessage` for
|
||||
# `space.getMessage`, `toInboundMessages` for the live stream), plus stubs of
|
||||
# the helpers they close over. Mirrors the published shape — tab-indented (via
|
||||
# `_tabify`), `const ... = async` declarations, single-line builder calls — so
|
||||
# the anchors exercise the real code path, and exporting the two functions lets
|
||||
# the test assert runtime behavior rather than only string shape.
|
||||
_SPECTRUM_IMESSAGE_FIXTURE = """
|
||||
const formatChildId = (partIndex, parentGuid) => `p:${partIndex}/${parentGuid}`;
|
||||
const asText = (text) => ({ type: "text", text });
|
||||
const asCustom = (message) => ({ type: "custom" });
|
||||
const asProviderGroup = (items) => ({ type: "group", items });
|
||||
const messageAttachments = (message) => message.content.attachments ?? [];
|
||||
const buildMessageBase = (message, chatGuidHint, timestamp, phone) => ({ direction: "inbound", sender: { id: "s" }, space: { id: "sp", type: "dm", phone }, timestamp });
|
||||
const buildAttachmentMessage = async (client, base, info, id, partIndex, parentId) => {
|
||||
const msg = { ...base, id, content: { type: "attachment", id: info.guid }, partIndex };
|
||||
if (parentId !== void 0) msg.parentId = parentId;
|
||||
return msg;
|
||||
};
|
||||
const cacheMessage = (cache, message) => { cache.set(message.id, message); };
|
||||
const rebuildFromAppleMessage = async (client, message, phone, chatGuidHint) => {
|
||||
const messageGuidStr = message.guid;
|
||||
const base = buildMessageBase(message, chatGuidHint, message.dateCreated ?? /* @__PURE__ */ new Date(), phone);
|
||||
const attachments = messageAttachments(message);
|
||||
if (attachments.length === 1) {
|
||||
const info = attachments[0];
|
||||
if (!info) throw new Error("Unreachable: attachments.length === 1 but no element");
|
||||
return buildAttachmentMessage(client, base, info, messageGuidStr, 0);
|
||||
}
|
||||
if (attachments.length > 1) {
|
||||
const items = [];
|
||||
for (let i = 0; i < attachments.length; i++) {
|
||||
const info = attachments[i];
|
||||
if (!info) continue;
|
||||
items.push(await buildAttachmentMessage(client, base, info, formatChildId(i, messageGuidStr), i, messageGuidStr));
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
id: messageGuidStr,
|
||||
content: asProviderGroup(items)
|
||||
};
|
||||
}
|
||||
const text = message.content.text;
|
||||
return {
|
||||
...base,
|
||||
id: messageGuidStr,
|
||||
content: text ? asText(text) : asCustom(message)
|
||||
};
|
||||
};
|
||||
const toInboundMessages = async (client, cache, event, phone) => {
|
||||
const base = buildMessageBase(event.message, event.chatGuid, event.occurredAt, phone);
|
||||
const messageGuidStr = event.message.guid;
|
||||
const attachments = messageAttachments(event.message);
|
||||
if (attachments.length === 1) {
|
||||
const info = attachments[0];
|
||||
if (!info) throw new Error("Unreachable: attachments.length === 1 but no element");
|
||||
const msg = await buildAttachmentMessage(client, base, info, messageGuidStr, 0);
|
||||
cacheMessage(cache, msg);
|
||||
return [msg];
|
||||
}
|
||||
if (attachments.length > 1) {
|
||||
const items = [];
|
||||
for (let i = 0; i < attachments.length; i++) {
|
||||
const info = attachments[i];
|
||||
if (!info) continue;
|
||||
items.push(await buildAttachmentMessage(client, base, info, formatChildId(i, messageGuidStr), i, messageGuidStr));
|
||||
}
|
||||
const parent = {
|
||||
...base,
|
||||
id: messageGuidStr,
|
||||
content: asProviderGroup(items)
|
||||
};
|
||||
cacheMessage(cache, parent);
|
||||
return [parent];
|
||||
}
|
||||
const text = event.message.content.text;
|
||||
const msg = {
|
||||
...base,
|
||||
id: messageGuidStr,
|
||||
content: text ? asText(text) : asCustom(event.message)
|
||||
};
|
||||
cacheMessage(cache, msg);
|
||||
return [msg];
|
||||
};
|
||||
export { rebuildFromAppleMessage, toInboundMessages };
|
||||
"""
|
||||
|
||||
|
||||
def _write_fixture(tmp_path: Path) -> Path:
|
||||
dist = tmp_path / "node_modules" / "@spectrum-ts" / "imessage" / "dist"
|
||||
dist.mkdir(parents=True)
|
||||
chunk = dist / "index.js"
|
||||
chunk.write_text(_tabify(_SPECTRUM_IMESSAGE_FIXTURE), encoding="utf-8")
|
||||
return chunk
|
||||
|
||||
|
||||
def test_spectrum_patch_rewrites_the_imessage_mapper(tmp_path: Path) -> None:
|
||||
"""The dependency patch must apply to the 8.x `@spectrum-ts/imessage` chunk
|
||||
and rewrite both inbound mappers to thread text through attachment bubbles."""
|
||||
chunk = _write_fixture(tmp_path)
|
||||
|
||||
result = subprocess.run(
|
||||
["node", str(_PATCHER), str(tmp_path)],
|
||||
cwd=Path.cwd(),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
patched = chunk.read_text(encoding="utf-8")
|
||||
assert "Preserve mixed text + attachment iMessage payloads" in patched
|
||||
# Single-attachment bubbles wrap the text + attachment in a group...
|
||||
assert "content: asProviderGroup([textMsg, msg2])" in patched # rebuild
|
||||
assert "content: asProviderGroup([textMsg, msg])" in patched # inbound
|
||||
# ...multi-attachment bubbles keep the group and shift attachment indices.
|
||||
assert "content: asProviderGroup(items)" in patched
|
||||
assert "formatChildId(text2 ? i + 1 : i, messageGuidStr)" in patched
|
||||
# The text is captured in both mappers before the attachment branches run.
|
||||
assert "const text2 = message.content.text;" in patched
|
||||
assert "const text2 = event.message.content.text;" in patched
|
||||
|
||||
# Re-running is a no-op (idempotent self-heal on every sidecar start).
|
||||
again = subprocess.run(
|
||||
["node", str(_PATCHER), str(tmp_path)],
|
||||
cwd=Path.cwd(),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
assert again.returncode == 0, again.stderr
|
||||
assert chunk.read_text(encoding="utf-8") == patched
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Regression tests for Photon adapter streaming behavior."""
|
||||
from plugins.platforms.photon.adapter import PhotonAdapter
|
||||
|
||||
|
||||
def test_photon_adapter_does_not_support_message_editing() -> None:
|
||||
"""PhotonAdapter.SUPPORTS_MESSAGE_EDITING must be False.
|
||||
|
||||
Photon (iMessage) has no real edit API for already-sent messages.
|
||||
This attribute signals the gateway to suppress the streaming cursor
|
||||
instead of leaving a stale tofu square (▉) behind when edit attempts fail.
|
||||
"""
|
||||
assert PhotonAdapter.SUPPORTS_MESSAGE_EDITING is False
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Behavior tests for Photon raw-URL outbound routing (issue: markdown 500s).
|
||||
|
||||
The iMessage markdown builder enables data detection inside spectrum-ts. On
|
||||
some IMAgentKit sends, that path returns a 500 when the message contains a raw
|
||||
URL. The sidecar keeps markdown rendering for URL-free messages, but must use
|
||||
plain text for messages containing URLs so iMessage can auto-link them without
|
||||
hitting the data-detection failure path.
|
||||
|
||||
The routing decision lives in
|
||||
``plugins/platforms/photon/sidecar/send-format.mjs`` (imported by index.mjs's
|
||||
``/send`` handler). These tests *execute* that real module under node and
|
||||
assert the chosen builder for representative payloads — they do not read the
|
||||
sidecar source.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Dict, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
_MODULE = Path("plugins/platforms/photon/sidecar/send-format.mjs").resolve()
|
||||
|
||||
_CASES: Dict[str, Tuple[str, str, str]] = {
|
||||
# name: (format, text, expected builder)
|
||||
"markdown_without_url_keeps_markdown": (
|
||||
"markdown", "**bold** and `code`", "markdown",
|
||||
),
|
||||
"markdown_with_https_url_falls_back_to_text": (
|
||||
"markdown", "see **this**: https://example.com/a?b=1", "text",
|
||||
),
|
||||
"markdown_with_http_url_falls_back_to_text": (
|
||||
"markdown", "http://example.com", "text",
|
||||
),
|
||||
"markdown_link_syntax_also_falls_back_to_text": (
|
||||
"markdown", "[docs](https://example.com/docs)", "text",
|
||||
),
|
||||
"markdown_with_uppercase_scheme_falls_back_to_text": (
|
||||
"markdown", "HTTPS://EXAMPLE.COM is loud", "text",
|
||||
),
|
||||
"markdown_bare_domain_without_scheme_keeps_markdown": (
|
||||
# Only scheme'd URLs trip iMessage's data-detection 500; a bare domain
|
||||
# is ordinary text and must keep markdown rendering.
|
||||
"markdown", "ask example.com about *this*", "markdown",
|
||||
),
|
||||
"text_format_stays_text": ("text", "plain message", "text"),
|
||||
"text_format_with_url_stays_text": (
|
||||
"text", "https://example.com", "text",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def verdicts() -> Dict[str, str]:
|
||||
"""Run every case through the real send-format module in one node call."""
|
||||
harness = (
|
||||
f"import {{ chooseSendFormat }} from {json.dumps(_MODULE.as_uri())};\n"
|
||||
"const chunks = [];\n"
|
||||
"process.stdin.on('data', (c) => chunks.push(c));\n"
|
||||
"process.stdin.on('end', () => {\n"
|
||||
" const cases = JSON.parse(Buffer.concat(chunks).toString('utf-8'));\n"
|
||||
" const out = {};\n"
|
||||
" for (const [name, [format, text]] of Object.entries(cases)) {\n"
|
||||
" out[name] = chooseSendFormat(format, text);\n"
|
||||
" }\n"
|
||||
" process.stdout.write(JSON.stringify(out));\n"
|
||||
"});\n"
|
||||
)
|
||||
payload = {name: [fmt, text] for name, (fmt, text, _) in _CASES.items()}
|
||||
run = subprocess.run(
|
||||
["node", "--input-type=module", "-e", harness],
|
||||
input=json.dumps(payload),
|
||||
cwd=Path.cwd(),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
assert run.returncode == 0, run.stderr
|
||||
return json.loads(run.stdout)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", sorted(_CASES))
|
||||
def test_send_builder_selection(name: str, verdicts: Dict[str, str]) -> None:
|
||||
_, _, expected = _CASES[name]
|
||||
assert verdicts[name] == expected
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Zombie-stream watchdog tests (half-open gRPC stream, issue #54036).
|
||||
|
||||
spectrum-ts only reconnects when its inbound iterator throws or ends; a
|
||||
half-open ("zombie") socket makes the iterator hang forever — no error, no
|
||||
end — so inbound silently dies while the sidecar process looks healthy.
|
||||
|
||||
The salvaged design has two layers:
|
||||
|
||||
1. Sidecar (node): ``stream-staleness.mjs`` decision rules + a watchdog in
|
||||
``index.mjs`` that tracks the iterator's last yield, probes only after a
|
||||
conservative silence threshold, and classifies degraded ONLY when a probe
|
||||
proves connectivity while the stream is silent (never on silence alone,
|
||||
never on an inconclusive probe). Degraded feeds the existing exit-75
|
||||
restart path and the ``staleness`` block on ``/healthz``.
|
||||
2. Adapter (python): ``_monitor_sidecar_health`` surfaces the new staleness
|
||||
fields; ``_probe_once`` has strict tri-state semantics (alive / hung /
|
||||
inconclusive).
|
||||
|
||||
These tests execute the real node decision module and drive the adapter
|
||||
against mocked ``/healthz`` responses — style follows
|
||||
test_overflow_recovery.py / test_spectrum_patch.py. No ports are bound and no
|
||||
gRPC traffic occurs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from plugins.platforms.photon.adapter import PhotonAdapter
|
||||
|
||||
_MODULE = Path("plugins/platforms/photon/sidecar/stream-staleness.mjs").resolve()
|
||||
|
||||
|
||||
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
return PhotonAdapter(cfg)
|
||||
|
||||
|
||||
# -- Sidecar decision rules (execute the real node module) -------------------
|
||||
|
||||
def _run_staleness_harness(script: str) -> Dict[str, Any]:
|
||||
harness = (
|
||||
"import { classifyProbeRejection, shouldProbe, isZombieSuspect } "
|
||||
f"from {json.dumps(_MODULE.as_uri())};\n"
|
||||
+ script
|
||||
)
|
||||
run = subprocess.run(
|
||||
["node", "--input-type=module", "-e", harness],
|
||||
cwd=Path.cwd(),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
assert run.returncode == 0, run.stderr
|
||||
return json.loads(run.stdout)
|
||||
|
||||
|
||||
def test_probe_rejection_classification_is_strict() -> None:
|
||||
"""Only not-found-shaped rejections prove liveness; everything else is
|
||||
inconclusive — a rejected probe is NEVER treated as alive (#45580's
|
||||
original /probe treated any rejection as alive, which was too loose)."""
|
||||
out = _run_staleness_harness(
|
||||
"""
|
||||
const results = {
|
||||
notFoundCode: classifyProbeRejection({ code: 5, message: "5 NOT_FOUND: nope" }),
|
||||
notFoundText: classifyProbeRejection(new Error("message not found")),
|
||||
sdkNotFound: classifyProbeRejection({ code: "notFound", message: "missing" }),
|
||||
unavailable: classifyProbeRejection({ code: 14, message: "14 UNAVAILABLE: connect failed" }),
|
||||
deadline: classifyProbeRejection({ code: 4, message: "4 DEADLINE_EXCEEDED" }),
|
||||
generic: classifyProbeRejection(new Error("socket hang up")),
|
||||
weird: classifyProbeRejection("string error"),
|
||||
};
|
||||
process.stdout.write(JSON.stringify(results));
|
||||
"""
|
||||
)
|
||||
# Completed round-trips (server said not-found for our synthetic id).
|
||||
for name in ("notFoundCode", "notFoundText", "sdkNotFound"):
|
||||
assert out[name]["alive"] is True, name
|
||||
assert out[name]["inconclusive"] is False, name
|
||||
# Everything else: not alive AND explicitly inconclusive.
|
||||
for name in ("unavailable", "deadline", "generic", "weird"):
|
||||
assert out[name]["alive"] is False, name
|
||||
assert out[name]["inconclusive"] is True, name
|
||||
|
||||
|
||||
def test_should_probe_requires_silence_past_threshold_and_cooldown() -> None:
|
||||
out = _run_staleness_harness(
|
||||
"""
|
||||
const MIN10 = 10 * 60 * 1000;
|
||||
const results = {
|
||||
quietButUnderThreshold: shouldProbe(MIN10 - 1, MIN10, MIN10, 120000),
|
||||
pastThreshold: shouldProbe(MIN10 + 1, MIN10, MIN10, 120000),
|
||||
pastThresholdButCoolingDown: shouldProbe(MIN10 + 1, MIN10, 1000, 120000),
|
||||
watchdogDisabled: shouldProbe(MIN10 * 100, 0, MIN10, 120000),
|
||||
watchdogDisabledNegative: shouldProbe(MIN10 * 100, -1, MIN10, 120000),
|
||||
};
|
||||
process.stdout.write(JSON.stringify(results));
|
||||
"""
|
||||
)
|
||||
assert out["quietButUnderThreshold"] is False
|
||||
assert out["pastThreshold"] is True
|
||||
assert out["pastThresholdButCoolingDown"] is False
|
||||
assert out["watchdogDisabled"] is False
|
||||
assert out["watchdogDisabledNegative"] is False
|
||||
|
||||
|
||||
def test_zombie_requires_probe_proven_connectivity_never_silence_alone() -> None:
|
||||
"""The core conservatism rule: shared lines can be quiet for hours, so a
|
||||
zombie is declared only when the stream is silent past threshold AND a
|
||||
probe PROVED the wire works (stream dead, channel alive)."""
|
||||
out = _run_staleness_harness(
|
||||
"""
|
||||
const MIN10 = 10 * 60 * 1000;
|
||||
const alive = { alive: true };
|
||||
const inconclusive = { alive: false };
|
||||
const results = {
|
||||
silentAndProbeAlive: isZombieSuspect(MIN10 * 2, MIN10, alive),
|
||||
silentButProbeInconclusive: isZombieSuspect(MIN10 * 2, MIN10, inconclusive),
|
||||
silentNoProbe: isZombieSuspect(MIN10 * 2, MIN10, null),
|
||||
hoursOfSilenceInconclusive: isZombieSuspect(MIN10 * 36, MIN10, inconclusive),
|
||||
notSilentEnough: isZombieSuspect(MIN10 - 1, MIN10, alive),
|
||||
disabled: isZombieSuspect(MIN10 * 2, 0, alive),
|
||||
};
|
||||
process.stdout.write(JSON.stringify(results));
|
||||
"""
|
||||
)
|
||||
assert out["silentAndProbeAlive"] is True
|
||||
# Silence alone — even 6 hours of it — is NEVER a zombie verdict.
|
||||
assert out["silentButProbeInconclusive"] is False
|
||||
assert out["silentNoProbe"] is False
|
||||
assert out["hoursOfSilenceInconclusive"] is False
|
||||
assert out["notSilentEnough"] is False
|
||||
assert out["disabled"] is False
|
||||
|
||||
|
||||
# -- Adapter surfacing of the new /healthz staleness fields ------------------
|
||||
|
||||
def _healthz_payload(**staleness: Any) -> Dict[str, Any]:
|
||||
return {
|
||||
"ok": True,
|
||||
"stream": {
|
||||
"ok": True,
|
||||
"state": "healthy",
|
||||
"degradedForMs": 0,
|
||||
"staleness": {
|
||||
"lastInboundAt": "2026-07-28T00:00:00.000Z",
|
||||
"silentForMs": 0,
|
||||
"silenceThresholdMs": 600000,
|
||||
"lastProbeAt": None,
|
||||
"lastProbeOutcome": None,
|
||||
"zombieSuspected": False,
|
||||
**staleness,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_monitor_surfaces_zombie_suspected_without_fatal(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""zombieSuspected on /healthz is surfaced as a warning while the stream
|
||||
is still 'ok' — the fatal path stays owned by the degraded state (the
|
||||
sidecar escalates to degraded -> exit 75 itself)."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
adapter._inbound_running = True
|
||||
adapter._sidecar_health_interval = 0.0
|
||||
|
||||
polls = 0
|
||||
|
||||
async def _fake_call(path: str, payload: Dict[str, Any]) -> Any:
|
||||
nonlocal polls
|
||||
assert path == "/healthz"
|
||||
polls += 1
|
||||
if polls >= 2:
|
||||
adapter._inbound_running = False
|
||||
return _healthz_payload(
|
||||
silentForMs=1_200_000,
|
||||
lastProbeOutcome="alive",
|
||||
zombieSuspected=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(adapter, "_sidecar_call", _fake_call)
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
await adapter._monitor_sidecar_health()
|
||||
|
||||
assert adapter.has_fatal_error is False
|
||||
assert any(
|
||||
"suspected zombie stream" in rec.message for rec in caplog.records
|
||||
)
|
||||
|
||||
|
||||
# -- Adapter watchdog: inconclusive never counts toward respawn --------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inconclusive_probes_never_accumulate_toward_respawn(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Strict semantics end-to-end at the adapter: a 503/transport-error probe
|
||||
(inconclusive) must not increment the failure counter the way the original
|
||||
#45580 booleans did — only hung probes do."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
|
||||
class _Resp503:
|
||||
status_code = 503
|
||||
|
||||
class _Client:
|
||||
async def post(self, *args: Any, **kwargs: Any) -> Any:
|
||||
return _Resp503()
|
||||
|
||||
adapter._http_client = _Client() # type: ignore[assignment]
|
||||
|
||||
# Many inconclusive probes in a row: mirror the watchdog's per-iteration
|
||||
# bookkeeping (only "hung" increments) and assert no failures accrue.
|
||||
for _ in range(10):
|
||||
verdict = await adapter._probe_once()
|
||||
assert verdict == "inconclusive"
|
||||
if verdict == "hung":
|
||||
adapter._probe_failures += 1
|
||||
|
||||
assert adapter._probe_failures == 0
|
||||
Reference in New Issue
Block a user