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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user