Files
Stirling-PDF/engine/tests/test_config_cache.py
T

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

112 lines
4.7 KiB
Python
Raw Normal View History

2026-07-23 08:29:06 +00:00
"""Tests for the encrypted config cache (stirling.config.config_cache)."""
from __future__ import annotations
import os
import stat
from pathlib import Path
import pytest
from stirling.config import config_cache
from stirling.contracts import ConfigPushRequest
def _sample() -> ConfigPushRequest:
return ConfigPushRequest.model_validate(
{
"models": {
"provider": "anthropic",
"smartModel": "claude-haiku-4-5",
"fastModel": "claude-haiku-4-5",
"smartMaxTokens": 8192,
"fastMaxTokens": 2048,
"apiKey": "secret-key-value",
"baseUrl": "",
},
"rag": {
"embeddingProvider": "voyageai",
"embeddingModel": "voyage-4",
"embeddingApiKey": "embed-secret",
"embeddingBaseUrl": "",
"topK": 20,
"maxSearches": 5,
},
"limits": {"maxPages": 200, "maxCharacters": 200000, "modelMaxConcurrency": 32},
}
)
def test_roundtrip_with_shared_secret(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setattr(config_cache, "_shared_secret", lambda: "the-shared-secret")
req = _sample()
config_cache.save_config(req, data_dir=tmp_path)
# HKDF-from-secret path: no keyfile is written.
assert not (tmp_path / "ai_config_cache.key").exists()
assert config_cache.load_config(data_dir=tmp_path) == req
# Secrets are encrypted at rest.
assert b"secret-key-value" not in (tmp_path / "ai_config_cache.enc").read_bytes()
def test_roundtrip_with_keyfile_when_no_secret(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setattr(config_cache, "_shared_secret", lambda: "")
req = _sample()
config_cache.save_config(req, data_dir=tmp_path)
# No shared secret: a random keyfile is generated and reused for decrypt.
assert (tmp_path / "ai_config_cache.key").exists()
assert config_cache.load_config(data_dir=tmp_path) == req
def test_load_missing_returns_none(tmp_path: Path) -> None:
assert config_cache.load_config(data_dir=tmp_path) is None
def test_load_corrupt_returns_none(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setattr(config_cache, "_shared_secret", lambda: "a-secret")
(tmp_path / "ai_config_cache.enc").write_bytes(b"not-a-valid-fernet-token")
assert config_cache.load_config(data_dir=tmp_path) is None
def test_wrong_key_returns_none(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setattr(config_cache, "_shared_secret", lambda: "secret-a")
config_cache.save_config(_sample(), data_dir=tmp_path)
# A different secret derives a different key -> decrypt fails, returns None.
monkeypatch.setattr(config_cache, "_shared_secret", lambda: "secret-b")
assert config_cache.load_config(data_dir=tmp_path) is None
@pytest.mark.skipif(os.name == "nt", reason="POSIX file modes are not enforced on Windows")
def test_cache_and_keyfile_are_owner_only(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Both files hold credential material, so neither may land at the umask default."""
monkeypatch.setattr(config_cache, "_shared_secret", lambda: "")
config_cache.save_config(_sample(), data_dir=tmp_path)
for name in ("ai_config_cache.enc", "ai_config_cache.key"):
mode = stat.S_IMODE((tmp_path / name).stat().st_mode)
assert mode == 0o600, f"{name} is {oct(mode)}, expected 0o600"
def test_save_leaves_no_temp_file_behind(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""The write goes via a temp file + rename so a reader never sees a partial cache."""
monkeypatch.setattr(config_cache, "_shared_secret", lambda: "the-shared-secret")
config_cache.save_config(_sample(), data_dir=tmp_path)
config_cache.save_config(_sample(), data_dir=tmp_path)
assert not list(tmp_path.glob("*.tmp"))
assert config_cache.load_config(data_dir=tmp_path) == _sample()
def test_cache_stamp_tracks_writes(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""cache_stamp is None before any write and changes when the file is rewritten."""
monkeypatch.setattr(config_cache, "_shared_secret", lambda: "the-shared-secret")
assert config_cache.cache_stamp(data_dir=tmp_path) is None
config_cache.save_config(_sample(), data_dir=tmp_path)
first = config_cache.cache_stamp(data_dir=tmp_path)
assert first is not None
bigger = _sample()
bigger.models.smart_model = "claude-haiku-4-5-with-a-much-longer-name-so-the-size-differs"
config_cache.save_config(bigger, data_dir=tmp_path)
assert config_cache.cache_stamp(data_dir=tmp_path) != first