Approved by Yua at e23d975. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
157 lines
6.2 KiB
Python
157 lines
6.2 KiB
Python
"""Status contract v1: read-only status, input-free rate-limited test, no key material."""
|
|
|
|
import json
|
|
from unittest.mock import MagicMock, patch
|
|
from urllib.error import HTTPError, URLError
|
|
|
|
import pytest
|
|
|
|
from immich_nodes import save_to_immich as node_mod
|
|
from immich_nodes import status
|
|
|
|
SENTINEL = "SENTINEL-kq7Z-KEY-93xw"
|
|
URL = "https://immich.example.test"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _configured(tmp_path):
|
|
status._last_test[0] = None
|
|
user_env = tmp_path / "comfyui-immich.env"
|
|
user_env.write_text(f"IMMICH_URL={URL}/api/\nIMMICH_API_KEY={SENTINEL}\n")
|
|
with patch.object(
|
|
node_mod, "_config_paths", return_value=(str(user_env), str(tmp_path / ".env"))
|
|
):
|
|
yield
|
|
status._last_test[0] = None
|
|
|
|
|
|
def _leaks(payload, key=SENTINEL):
|
|
raw = json.dumps(payload)
|
|
return any(fragment in raw for fragment in (key, key[:4], key[-4:]))
|
|
|
|
|
|
def _ok_response(code=200):
|
|
resp = MagicMock()
|
|
resp.status = code
|
|
resp.__enter__ = lambda s: s
|
|
resp.__exit__ = MagicMock(return_value=False)
|
|
return resp
|
|
|
|
|
|
class TestNoKeyMaterial:
|
|
def test_status_has_no_key_material(self):
|
|
payload = status.status_payload()
|
|
assert payload["key_set"] is True
|
|
assert payload["url"] == URL
|
|
assert not _leaks(payload)
|
|
|
|
@pytest.mark.parametrize(
|
|
"outcome",
|
|
[_ok_response(), HTTPError(URL, 401, "no", {}, None), URLError("down")],
|
|
)
|
|
def test_test_results_have_no_key_material(self, outcome):
|
|
with patch("immich_nodes.save_to_immich.urlopen") as mock_open:
|
|
if isinstance(outcome, Exception):
|
|
mock_open.side_effect = outcome
|
|
else:
|
|
mock_open.return_value = outcome
|
|
_code, payload = status.run_connection_test(False)
|
|
assert not _leaks(payload)
|
|
|
|
def test_mutation_proof_detector_catches_a_leaky_status(self):
|
|
leaky = dict(status.status_payload(), key=SENTINEL)
|
|
assert _leaks(leaky)
|
|
assert _leaks({"key_hint": SENTINEL[-4:]})
|
|
|
|
|
|
class TestTestRoute:
|
|
def test_input_is_refused_and_nothing_is_sent(self):
|
|
with patch("immich_nodes.save_to_immich.urlopen") as mock_open:
|
|
code, payload = status.run_connection_test(True)
|
|
assert code == 400
|
|
assert payload["error"] == "input_not_accepted"
|
|
mock_open.assert_not_called()
|
|
|
|
def test_single_request_goes_to_saved_url_with_saved_key(self):
|
|
with patch("immich_nodes.save_to_immich.urlopen", return_value=_ok_response()) as mock_open:
|
|
code, payload = status.run_connection_test(False)
|
|
assert (code, payload) == (200, {"ok": True, "status": 200, "error": None})
|
|
assert mock_open.call_count == 1
|
|
req = mock_open.call_args.args[0]
|
|
assert req.full_url == URL + "/api/users/me"
|
|
assert req.get_header("X-api-key") == SENTINEL
|
|
|
|
def test_rate_limited_within_window(self):
|
|
times = iter([100.0, 101.0, 106.0])
|
|
with patch("immich_nodes.save_to_immich.urlopen", return_value=_ok_response()):
|
|
first = status.run_connection_test(False, clock=lambda: next(times))
|
|
second = status.run_connection_test(False, clock=lambda: next(times))
|
|
third = status.run_connection_test(False, clock=lambda: next(times))
|
|
assert first[0] == 200
|
|
assert second == (429, {"ok": False, "status": None, "error": "rate_limited"})
|
|
assert third[0] == 200
|
|
|
|
@pytest.mark.parametrize(
|
|
("code", "error"),
|
|
[
|
|
(401, "unauthorized"),
|
|
(403, "forbidden"),
|
|
(302, "redirect_refused"),
|
|
(500, "bad_response"),
|
|
],
|
|
)
|
|
def test_http_errors_become_classes(self, code, error):
|
|
with patch(
|
|
"immich_nodes.save_to_immich.urlopen", side_effect=HTTPError(URL, code, "x", {}, None)
|
|
):
|
|
_code, payload = status.run_connection_test(False)
|
|
assert payload == {"ok": False, "status": code, "error": error}
|
|
|
|
def test_not_configured_makes_no_request(self, tmp_path):
|
|
(tmp_path / "comfyui-immich.env").write_text(f"IMMICH_URL={URL}\n")
|
|
with patch("immich_nodes.save_to_immich.urlopen") as mock_open:
|
|
_code, payload = status.run_connection_test(False)
|
|
assert payload["error"] == "not_configured"
|
|
mock_open.assert_not_called()
|
|
|
|
|
|
class TestConfigPrecedence:
|
|
def test_userdir_then_node_dotenv_and_environment_ignored(self, tmp_path):
|
|
user_env = tmp_path / "comfyui-immich.env"
|
|
node_env = tmp_path / ".env"
|
|
user_env.write_text("IMMICH_URL=https://from-user.test\n")
|
|
node_env.write_text("IMMICH_URL=https://from-node.test\nIMMICH_API_KEY=node-key\n")
|
|
with (
|
|
patch.object(node_mod, "_config_paths", return_value=(str(user_env), str(node_env))),
|
|
patch.dict(
|
|
"os.environ", {"IMMICH_URL": "https://from-env.test", "IMMICH_API_KEY": "env-key"}
|
|
),
|
|
):
|
|
config = node_mod.resolve_config()
|
|
assert (config["url"], config["url_source"]) == ("https://from-user.test", "userdir")
|
|
# No key in the user file: the legacy node .env fills in, never the environment.
|
|
assert (config["key"], config["key_source"]) == ("node-key", "dotenv")
|
|
|
|
def test_node_dotenv_is_last_resort(self, tmp_path):
|
|
node_env = tmp_path / ".env"
|
|
node_env.write_text("IMMICH_URL=https://from-node.test\nIMMICH_API_KEY=node-key\n")
|
|
with patch.object(node_mod, "_config_paths", return_value=(None, str(node_env))):
|
|
config = node_mod.resolve_config()
|
|
assert config["url_source"] == config["key_source"] == "dotenv"
|
|
|
|
def test_environment_alone_configures_nothing(self, tmp_path):
|
|
with (
|
|
patch.object(node_mod, "_config_paths", return_value=(None, str(tmp_path / ".env"))),
|
|
patch.dict(
|
|
"os.environ", {"IMMICH_URL": "https://from-env.test", "IMMICH_API_KEY": "k"}
|
|
),
|
|
):
|
|
config = node_mod.resolve_config()
|
|
assert (config["url"], config["key"]) == ("", "")
|
|
assert config["url_source"] == config["key_source"] == "none"
|
|
|
|
def test_routes_register_is_a_noop_outside_comfyui(self):
|
|
from immich_nodes import routes
|
|
|
|
assert routes.register() is False
|