257 lines
8.5 KiB
Python
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
|