Files
2026-09-26 14:20:55 -04:00

244 lines
9.9 KiB
Python

"""Saving Immich settings from ComfyUI's Settings panel (immich_nodes/settings.py)."""
import json
import os
from unittest.mock import patch
import pytest
from immich_nodes import save_to_immich as node_mod
from immich_nodes import settings, status
SENTINEL = "SENTINEL-settings-KEY-7c2e"
ORIGIN = "http://127.0.0.1:8188"
HOST = "127.0.0.1:8188"
@pytest.fixture
def paths(tmp_path):
"""A writable ComfyUI user directory, an empty node folder, and a clean environment."""
user_env = tmp_path / "user" / "comfyui-immich.env"
node_env = tmp_path / "node" / ".env"
node_env.parent.mkdir()
with (
patch.dict(os.environ, {}, clear=True),
patch.object(node_mod, "_config_paths", return_value=(str(user_env), str(node_env))),
):
yield user_env, node_env
def save(body, origin=ORIGIN, scheme="http", host=HOST, content_type="application/json"):
raw = body if isinstance(body, bytes) else json.dumps(body).encode()
return settings.save_request(origin, scheme, host, content_type, raw)
def saved(user_env):
return node_mod._load_env(str(user_env))
def test_save_writes_user_dir_and_never_returns_the_key(paths):
user_env, _ = paths
code, body = save(
{"url": "https://immich.example/", "confirm_url_change": True, "api_key": SENTINEL}
)
assert code == 200 and body["ok"] is True
assert body["url"] == "https://immich.example" and body["key_set"] is True
assert SENTINEL not in json.dumps(body)
assert SENTINEL not in json.dumps(status.status_payload())
assert saved(user_env) == {"IMMICH_URL": "https://immich.example", "IMMICH_API_KEY": SENTINEL}
assert (user_env.stat().st_mode & 0o777) == 0o600 # the file holds the key
def test_trailing_api_is_normalised_like_the_node_does(paths):
user_env, _ = paths
save({"url": "https://immich.example/api", "confirm_url_change": True, "api_key": SENTINEL})
assert saved(user_env)["IMMICH_URL"] == "https://immich.example"
@pytest.mark.parametrize(
("kw", "code", "error"),
[
({"origin": ""}, 403, "cross_origin"),
({"origin": "https://evil.example"}, 403, "cross_origin"),
# Same host and port, different scheme: a different origin to a browser.
({"origin": "https://same.example:8188", "host": "same.example:8188"}, 403, "cross_origin"),
({"origin": "http://127.0.0.1:8188/x"}, 403, "cross_origin"),
({"origin": "null"}, 403, "cross_origin"),
({"content_type": "text/plain"}, 415, "json_required"),
],
ids=[
"no origin",
"cross-site",
"scheme differs",
"origin with path",
"opaque origin",
"form post",
],
)
def test_requests_a_malicious_page_could_send_are_refused(paths, kw, code, error):
user_env, _ = paths
got_code, body = save({"api_key": SENTINEL}, **kw)
assert (got_code, body["error"]) == (code, error)
assert not user_env.exists()
@pytest.mark.parametrize(
("kw", "env"),
[
({"origin": "http://same.example", "host": "same.example:80"}, {}),
({"origin": "https://same.example", "host": "same.example:443", "scheme": "https"}, {}),
({"origin": "http://[::1]:8188", "host": "[::1]:8188"}, {}),
# TLS reverse proxy: the browser sees https, ComfyUI sees plain http.
(
{"origin": "https://comfy.example", "host": "comfy.example"},
{"IMMICH_ALLOWED_ORIGINS": "https://comfy.example"}, # set by hand in the user file
),
],
ids=["default http port", "default https port", "ipv6", "allowed proxy origin"],
)
def test_same_origin_accepts_the_page_itself(paths, kw, env):
user_env, _ = paths
if env:
user_env.parent.mkdir(parents=True, exist_ok=True)
user_env.write_text("".join(f"{k}={v}\n" for k, v in env.items()))
code, _ = save({"api_key": SENTINEL}, **kw)
assert code == 200
def test_oversized_and_malformed_bodies_are_refused(paths):
assert save(b"{" + b" " * settings.MAX_BODY_BYTES + b"}")[0] == 413
assert save(b"not json")[1]["error"] == "invalid_json"
assert save(b"[1]")[1]["error"] == "invalid_json"
def test_changing_the_url_needs_confirmation(paths):
user_env, _ = paths
code, body = save({"url": "https://immich.example"})
assert (code, body["error"]) == (409, "confirm_url_change")
assert not user_env.exists()
def test_changing_the_url_drops_the_old_key_unless_a_new_one_is_sent(paths):
user_env, _ = paths
save({"url": "https://good.example", "confirm_url_change": True, "api_key": SENTINEL})
code, body = save({"url": "https://attacker.example", "confirm_url_change": True})
assert code == 200 and body["key_set"] is False
assert "IMMICH_API_KEY" not in saved(user_env)
save({"url": "https://good.example", "confirm_url_change": True, "api_key": "new-key"})
assert saved(user_env)["IMMICH_API_KEY"] == "new-key"
def test_saving_the_same_url_keeps_the_key(paths):
save({"url": "https://good.example", "confirm_url_change": True, "api_key": SENTINEL})
code, body = save(
{"url": "https://good.example/"}
) # unchanged after normalising: no confirm needed
assert code == 200 and body["key_set"] is True
def test_clear_key(paths):
save({"url": "https://good.example", "confirm_url_change": True, "api_key": SENTINEL})
code, body = save({"clear_api_key": True})
assert code == 200 and body["key_set"] is False
@pytest.mark.parametrize("new_key", [None, "replacement"], ids=["no new key", "with new key"])
def test_url_change_refused_when_the_key_lives_in_the_legacy_node_dotenv(paths, new_key):
# Deleting the panel's copy would leave the node .env key in force, and it
# would be sent to the new server.
_, node_env = paths
settings.write_user_settings({"IMMICH_URL": "https://good.example"})
node_env.write_text("IMMICH_API_KEY=OLD-KEY\n")
body = {"url": "https://new.example", "confirm_url_change": True}
if new_key:
body["api_key"] = new_key
assert settings.plan_settings_update(body) == ({}, "key_outside_panel")
code, got = save(body)
assert (code, got["error"]) == (409, "key_outside_panel")
assert node_mod.resolve_config()["url"] == "https://good.example"
def test_an_environment_key_neither_blocks_nor_follows_a_url_change(paths):
settings.write_user_settings(
{"IMMICH_URL": "https://good.example", "IMMICH_API_KEY": "PANEL-KEY"}
)
with patch.dict(os.environ, {"IMMICH_API_KEY": "ENV-KEY", "IMMICH_URL": "https://env.example"}):
code, got = save({"url": "https://new.example", "confirm_url_change": True})
assert code == 200 and got["url"] == "https://new.example" and got["key_set"] is False
assert node_mod.resolve_config()["key"] == ""
def test_clear_key_refused_when_the_key_lives_in_the_legacy_node_dotenv(paths):
user_env, node_env = paths
settings.write_user_settings({"IMMICH_API_KEY": "PANEL-KEY"})
node_env.write_text("IMMICH_API_KEY=NODE-KEY\n")
code, got = save({"clear_api_key": True})
assert (code, got["error"]) == (409, "key_outside_panel")
assert saved(user_env)["IMMICH_API_KEY"] == "PANEL-KEY"
def test_clear_key_ignores_an_environment_key(paths):
settings.write_user_settings({"IMMICH_API_KEY": "PANEL-KEY"})
with patch.dict(os.environ, {"IMMICH_API_KEY": "ENV-KEY"}):
code, got = save({"clear_api_key": True})
assert code == 200 and got["key_set"] is False
@pytest.mark.parametrize(
("body", "error"),
[
({"url": "https://user:pw@immich.example", "confirm_url_change": True}, "invalid_url"),
({"url": "https://immich.example/?token=x", "confirm_url_change": True}, "invalid_url"),
({"url": "ftp://immich.example", "confirm_url_change": True}, "invalid_url"),
({"url": 5, "confirm_url_change": True}, "invalid_url"),
({"api_key": " "}, "invalid_api_key"),
({"api_key": "a\nIMMICH_URL=https://evil.example"}, "invalid_value"),
({"key": SENTINEL}, "unknown_field"),
],
)
def test_invalid_input_is_refused_and_nothing_is_written(paths, body, error):
user_env, _ = paths
code, got = save(body)
assert code == 400 and got["error"] == error
assert not user_env.exists()
def test_environment_variables_no_longer_override_the_panel(paths):
save({"url": "https://panel.example", "confirm_url_change": True, "api_key": SENTINEL})
with patch.dict(os.environ, {"IMMICH_URL": "https://env.example", "IMMICH_API_KEY": "env"}):
payload = status.status_payload()
assert payload["url"] == "https://panel.example"
assert payload["source"] == {"url": "userdir", "key": "userdir"}
assert "shadowed" not in payload
def test_status_never_reports_an_absolute_path(paths, tmp_path):
_, body = save(
{"url": "https://immich.example", "confirm_url_change": True, "api_key": SENTINEL}
)
for payload in (body, status.status_payload()):
text = json.dumps(payload)
assert str(tmp_path) not in text
assert "config_path" not in payload and "user_config_path" not in payload
assert payload["config_location"] == "ComfyUI user directory (comfyui-immich.env)"
def test_no_user_directory_means_no_save(tmp_path):
with (
patch.dict(os.environ, {}, clear=True),
patch.object(node_mod, "_config_paths", return_value=(None, str(tmp_path / ".env"))),
):
code, body = save({"api_key": SENTINEL})
assert status.status_payload()["writable"] is False
assert (code, body["error"]) == (503, "no_user_directory")
def test_url_edit_applies_even_when_the_environment_sets_a_url(paths):
"""Before 0.6.0 this was refused as url_shadowed; the environment is no longer read."""
user_env, _ = paths
settings.write_user_settings({"IMMICH_API_KEY": "PANEL-KEY"})
with patch.dict(os.environ, {"IMMICH_URL": "https://env.example"}):
code, got = save({"url": "https://panel.example", "confirm_url_change": True})
assert code == 200 and got["url"] == "https://panel.example"
assert saved(user_env)["IMMICH_URL"] == "https://panel.example"