Files
raycast-api/tests/test_config.py
T

257 lines
8.5 KiB
Python

"""Tests for `raycast_api.config` and `raycast_api.discovery.cache`."""
from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
from raycast_api.config import DEFAULT_BACKEND_URL, Config, ConfigComparison
from raycast_api.discovery.cache import DiscoveryCache, default_cache_dir
from raycast_api.errors import ConfigError, DiscoveryError
from raycast_api.signing_spec import RotRange, SigningSpec
FAKE_SECRET = ("DEAD" + "BEEF" * 15).lower()
def _make_spec() -> SigningSpec:
return SigningSpec(
rot_fn_name="r",
signing_fn_name="s",
rot_ranges=[RotRange(65, 90, 13), RotRange(97, 122, 13), RotRange(48, 57, 5)],
)
def _make_config() -> Config:
return Config(
signature_secret=FAKE_SECRET,
signing_spec=_make_spec(),
app_version="1.2.3",
user_agent="Raycast/1.2.3 (x-macOS Version 14.0)",
bundle_hash="0" * 64,
launcher_hash="1" * 64,
)
def test_roundtrip_serialization(tmp_path: Path) -> None:
cfg = _make_config()
out = tmp_path / "config.json"
cfg.save(out)
loaded = Config.load(out)
assert loaded.to_dict() == cfg.to_dict()
def test_save_creates_parent_dirs_and_sets_perms(tmp_path: Path) -> None:
cfg = _make_config()
out = tmp_path / "deep" / "nested" / "config.json"
cfg.save(out)
assert out.exists()
mode = out.stat().st_mode & 0o777
assert mode == 0o600
def test_redacted_secret() -> None:
cfg = _make_config()
assert cfg.redacted_secret() == "" + FAKE_SECRET[-4:]
short = Config(
signature_secret="abc",
signing_spec=_make_spec(),
app_version="x",
user_agent="x",
bundle_hash="x",
launcher_hash="x",
)
assert short.redacted_secret() == "***"
def test_load_rejects_missing_required(tmp_path: Path) -> None:
bad = tmp_path / "bad.json"
bad.write_text('{"signing_spec": {}}')
with pytest.raises(ConfigError):
Config.load(bad)
def test_default_constants() -> None:
cfg = _make_config()
assert cfg.backend_url == DEFAULT_BACKEND_URL
assert cfg.api_prefix == "/api/v1"
assert cfg.experimental_header == "autoModels"
def test_discover_from_app_synthetic(mock_app: Path, isolated_cache: Path) -> None:
cfg = Config.discover_from_app(mock_app)
assert cfg.signature_secret == FAKE_SECRET
assert cfg.app_version == "9.9.9.0"
assert cfg.signing_spec.signing_fn_name == "SignReq"
assert cfg.signing_spec.rot_fn_name in {"RotXform", "RotXform2"}
assert cfg.user_agent.startswith("Raycast/9.9.9.0 (x-macOS Version ")
def test_discover_from_app_rejects_missing(tmp_path: Path) -> None:
with pytest.raises(DiscoveryError):
Config.discover_from_app(tmp_path / "does-not-exist.app")
def test_cache_hit(mock_app: Path, isolated_cache: Path) -> None:
cfg1 = Config.discover_from_app(mock_app)
cache_files = list((isolated_cache / "raycast-api").glob("*.json"))
assert len(cache_files) == 1
data: dict[str, Any] = __import__("json").loads(cache_files[0].read_text())
data["app_version"] = "TAMPERED"
cache_files[0].write_text(__import__("json").dumps(data))
cfg2 = Config.discover_from_app(mock_app)
assert cfg2.app_version == "TAMPERED"
assert cfg2.bundle_hash == cfg1.bundle_hash
def test_cache_bypass(mock_app: Path, isolated_cache: Path) -> None:
Config.discover_from_app(mock_app)
cfg2 = Config.discover_from_app(mock_app, use_cache=False)
assert cfg2.app_version == "9.9.9.0"
def test_default_cache_dir_honors_xdg(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path))
assert default_cache_dir() == tmp_path / "raycast-api"
def test_discovery_cache_clear(tmp_path: Path) -> None:
cache = DiscoveryCache(root=tmp_path)
cfg = _make_config()
cache.set("abc", cfg)
assert (tmp_path / "abc.json").exists()
cache.clear()
assert not (tmp_path / "abc.json").exists()
def test_discovery_cache_corrupt_returns_none(tmp_path: Path) -> None:
cache = DiscoveryCache(root=tmp_path)
(tmp_path / "abc.json").write_text("not json")
assert cache.get("abc") is None
def test_cache_key_depends_on_both_hashes() -> None:
base = _make_config()
different_bundle = Config(
signature_secret=base.signature_secret,
signing_spec=base.signing_spec,
app_version=base.app_version,
user_agent=base.user_agent,
bundle_hash="9" * 64,
launcher_hash=base.launcher_hash,
)
different_launcher = Config(
signature_secret=base.signature_secret,
signing_spec=base.signing_spec,
app_version=base.app_version,
user_agent=base.user_agent,
bundle_hash=base.bundle_hash,
launcher_hash="9" * 64,
)
assert base.cache_key() != different_bundle.cache_key()
assert base.cache_key() != different_launcher.cache_key()
assert different_bundle.cache_key() != different_launcher.cache_key()
def test_launcher_rebuild_invalidates_cache(
mock_app: Path, isolated_cache: Path
) -> None:
"""A launcher-only change must still invalidate the cache, even if the JS bundle is byte-identical."""
cfg1 = Config.discover_from_app(mock_app)
binary_path = mock_app / "Contents" / "MacOS" / "Foo"
current = binary_path.read_bytes()
binary_path.write_bytes(current + b"\x99" * 16)
cfg2 = Config.discover_from_app(mock_app)
assert cfg2.launcher_hash != cfg1.launcher_hash
assert cfg2.cache_key() != cfg1.cache_key()
class TestCompareWithApp:
"""Freshness comparison between a saved Config and a local app on disk."""
def test_freshly_discovered_is_current(
self, mock_app: Path, isolated_cache: Path
) -> None:
cfg = Config.discover_from_app(mock_app)
cmp = cfg.compare_with_app(mock_app)
assert cmp.is_current
assert cmp.bundle_matches
assert cmp.launcher_matches
assert cmp.app_version_matches
assert cmp.reasons() == []
assert cfg.is_current_for(mock_app) is True
def test_bundle_change_is_stale(self, mock_app: Path, isolated_cache: Path) -> None:
cfg = Config.discover_from_app(mock_app)
mjs = (
mock_app
/ "Contents"
/ "Resources"
/ "test_RaycastDesktopApp.bundle"
/ "Contents"
/ "Resources"
/ "backend"
/ "index.mjs"
)
mjs.write_text(mjs.read_text() + "\n// touch")
cmp = cfg.compare_with_app(mock_app)
assert not cmp.is_current
assert cmp.bundle_matches is False
assert cmp.launcher_matches is True
assert "bundle rebuilt" in cmp.reasons()[0]
assert cfg.is_current_for(mock_app) is False
def test_launcher_change_is_stale(
self, mock_app: Path, isolated_cache: Path
) -> None:
cfg = Config.discover_from_app(mock_app)
binary = mock_app / "Contents" / "MacOS" / "Foo"
binary.write_bytes(binary.read_bytes() + b"\x77" * 8)
cmp = cfg.compare_with_app(mock_app)
assert not cmp.is_current
assert cmp.bundle_matches is True
assert cmp.launcher_matches is False
assert any("launcher rebuilt" in r for r in cmp.reasons())
def test_app_version_drift_alone_is_not_stale(
self, mock_app: Path, isolated_cache: Path
) -> None:
"""Version-only drift is informational — `is_current` stays True."""
cfg = Config.discover_from_app(mock_app)
from dataclasses import replace
cfg_drift = replace(cfg, app_version="0.0.0")
cmp = cfg_drift.compare_with_app(mock_app)
assert cmp.is_current
assert cmp.app_version_matches is False
assert any("app version" in r for r in cmp.reasons())
def test_missing_dir_raises(self, tmp_path: Path) -> None:
cfg = _make_config()
with pytest.raises(DiscoveryError, match="not a directory"):
cfg.compare_with_app(tmp_path / "nope.app")
def test_config_comparison_dataclass_is_frozen() -> None:
"""Ensure the comparison can be used as a hashable value (e.g. in a set)."""
cmp = ConfigComparison(
bundle_matches=True,
launcher_matches=True,
app_version_matches=True,
saved_bundle_hash="a",
current_bundle_hash="a",
saved_launcher_hash="b",
current_launcher_hash="b",
saved_app_version="1",
current_app_version="1",
)
with pytest.raises(Exception): # noqa: B017, BLE001 — FrozenInstanceError
cmp.bundle_matches = False # type: ignore[misc]
assert cmp.is_current