Files
MajoorWaldi-ComfyUI-Majoor-…/tests/features/test_settings_backend.py
T
Ewald a2053d0cba refactor(v2): plan V2 complet — splits JS, tests frontend, durcissement securite
Chantier F - Typage Python
- ruff S101 déplacé en per-file-ignores (pyproject.toml)
- pyrightconfig.json : mode standard confirmé

Chantier G - Assertions & bare-except
- 16 blocs except documentés dans config.py
- --cov-branch activé, seuil coverage 60→68%

Chantier H - Cache settings
- Stratégie documentée dans settings.py
- _resolve_index_dir() logguée dans config.py

Chantier I - Split gros fichiers JS
- client.js 1509L → client.js 406L + clientAuth.js + clientOps.js + fetchUtils.js
- toolbar.js 927L → toolbar.js 458L + toolbarActions.js + toolbarControls.js
- FloatingViewer.js → FloatingViewer.impl.js + floatingViewer*(8 modules)
- Viewer_impl.js → ViewerRuntime.js + viewerShell.js + viewerThemeStyles.js + viewerOverlayDismiss.js
- model3dRenderer.js → model3dRenderer.impl.js + model3dCore.js + model3dLoaders.js + model3dSupport.js
- dialogs.js → dialogs.js + DialogTemplates.js

Chantier J - Tests frontend (Phases 1-2)
- use_drag_drop.vitest.mjs : 5 tests
- use_grid_state.vitest.mjs : 18 tests
- security_client_headers, security_deep_merge, security_runtime_state, runtime_store, dialog_templates
- Total: 80 fichiers test / 350 tests vitest

Chantier K - Pinia migration
- runtimeState.js entièrement migré vers Pinia
- getOptionalRuntimeStore.js + runtimeEnrichmentState.js ajoutés

Chantier L - Durcissement sécurité
- tests/security/test_rate_limit.py : 8 tests rate-limit (state machine complète)
- clientAuth.js : docstring sessionStorage security boundary
- three.js 0.183.2 audité : 0 CVE (Snyk)

Chantier M - Simplification config
- deps.py, tool_detect.py, utils.py nettoyés

docs: PLAN_REFACTO_V2_LONG_TERME.md ajouté et clôturé
.gitignore: coverage/ ajouté
2026-04-11 09:38:41 +02:00

532 lines
17 KiB
Python

import os
import sys
from pathlib import Path
import pytest
from mjr_am_backend.routes.core import security as sec
from mjr_am_backend.settings import (
_AI_VERBOSE_LOGS_KEY,
_METADATA_FALLBACK_IMAGE_KEY,
_METADATA_FALLBACK_MEDIA_KEY,
_OUTPUT_DIRECTORY_KEY,
_PROBE_BACKEND_KEY,
_SECURITY_API_TOKEN_HASH_KEY,
_SECURITY_API_TOKEN_KEY,
_SETTINGS_VERSION_KEY,
AppSettings,
)
from mjr_am_backend.shared import Result
class _DB:
def __init__(self):
self.store = {}
self.fail_write = False
self.fail_delete = False
async def aquery(self, sql, params=()):
if "SELECT value FROM metadata" in sql:
key = params[0]
if key in self.store:
return Result.Ok([{"value": self.store[key]}])
return Result.Ok([])
return Result.Ok([])
async def aexecute(self, sql, params=()):
if "INSERT OR REPLACE INTO metadata" in sql:
if self.fail_write:
return Result.Err("DB", "write-failed")
self.store[str(params[0])] = str(params[1])
return Result.Ok("ok")
if "DELETE FROM metadata" in sql:
if self.fail_delete:
return Result.Err("DB", "delete-failed")
self.store.pop(str(params[0]), None)
return Result.Ok("ok")
return Result.Ok("ok")
@pytest.fixture(autouse=True)
def _clean_token_env(monkeypatch):
keys = (
"MAJOOR_API_TOKEN",
"MJR_API_TOKEN",
"MAJOOR_API_TOKEN_HASH",
"MJR_API_TOKEN_HASH",
"MAJOOR_API_TOKEN_PEPPER",
)
for k in keys:
monkeypatch.delenv(k, raising=False)
yield
try:
sec._reset_security_state_for_tests()
except Exception:
pass
for k in keys:
monkeypatch.delenv(k, raising=False)
@pytest.mark.asyncio
async def test_read_write_delete_setting_smoke():
db = _DB()
s = AppSettings(db)
assert await s._read_setting("x") is None
w = await s._write_setting("x", "1")
assert w.ok
assert await s._read_setting("x") == "1"
d = await s._delete_setting("x")
assert d.ok
assert await s._read_setting("x") is None
def test_hash_and_token_env(monkeypatch):
s = AppSettings(_DB())
monkeypatch.setenv("MAJOOR_API_TOKEN_PEPPER", "pep")
h = s._hash_api_token("abc")
assert h and len(h) == 64
s._set_api_token_env("tok", "hash", include_plain=True)
assert os.environ.get("MAJOOR_API_TOKEN") == "tok"
s._set_api_token_env("tok", "hash2", include_plain=False)
assert os.environ.get("MAJOOR_API_TOKEN") is None
assert os.environ.get("MAJOOR_API_TOKEN_HASH") == "hash2"
monkeypatch.delenv("MAJOOR_API_TOKEN_HASH", raising=False)
monkeypatch.delenv("MJR_API_TOKEN_HASH", raising=False)
monkeypatch.delenv("MJR_API_TOKEN", raising=False)
@pytest.mark.asyncio
async def test_get_or_create_api_token_from_env(monkeypatch):
db = _DB()
s = AppSettings(db)
monkeypatch.setenv("MAJOOR_API_TOKEN", "envtok")
t = await s._get_or_create_api_token_locked()
assert t == "envtok"
assert db.store.get(_SECURITY_API_TOKEN_HASH_KEY)
@pytest.mark.asyncio
async def test_get_or_create_api_token_from_hash_loads_hash_without_regeneration(monkeypatch):
db = _DB()
s = AppSettings(db)
db.store[_SECURITY_API_TOKEN_HASH_KEY] = s._hash_api_token("old")
monkeypatch.delenv("MAJOOR_API_TOKEN", raising=False)
t = await s._get_or_create_api_token_locked()
assert t == ""
assert os.environ.get("MAJOOR_API_TOKEN") is None
assert os.environ.get("MAJOOR_API_TOKEN_HASH") == db.store[_SECURITY_API_TOKEN_HASH_KEY]
@pytest.mark.asyncio
async def test_security_prefs_defaults_and_set(monkeypatch):
db = _DB()
s = AppSettings(db)
monkeypatch.setenv("MAJOOR_ALLOW_DELETE", "0")
prefs = await s.get_security_prefs()
assert isinstance(prefs["allow_delete"], bool)
out = await s.set_security_prefs(
{
"allow_write": True,
"require_auth": True,
"allow_insecure_token_transport": True,
"apiToken": "abc",
}
)
assert out.ok
assert db.store.get("allow_write") == "1"
assert db.store.get("require_auth") == "1"
assert db.store.get("allow_insecure_token_transport") == "1"
assert os.environ.get("MAJOOR_REQUIRE_AUTH") == "1"
assert os.environ.get("MAJOOR_ALLOW_INSECURE_TOKEN_TRANSPORT") == "1"
assert db.store.get(_SECURITY_API_TOKEN_KEY) == "abc"
assert db.store.get(_SECURITY_API_TOKEN_HASH_KEY)
@pytest.mark.asyncio
async def test_security_prefs_invalid_and_db_error():
db = _DB()
s = AppSettings(db)
bad = await s.set_security_prefs({})
assert bad.code == "INVALID_INPUT"
db.fail_write = True
err = await s.set_security_prefs({"allow_write": True})
assert err.code == "DB_ERROR"
@pytest.mark.asyncio
async def test_rotate_and_bootstrap_token():
db = _DB()
s = AppSettings(db)
rot = await s.rotate_api_token()
assert rot.ok and rot.data.get("api_token")
assert db.store.get(_SECURITY_API_TOKEN_KEY) == rot.data.get("api_token")
boot = await s.bootstrap_api_token()
assert boot.ok and boot.data.get("api_token")
@pytest.mark.asyncio
async def test_bootstrap_token_can_be_recovered_after_restart_when_managed_by_settings():
db = _DB()
first = AppSettings(db)
saved = await first.set_security_prefs({"apiToken": "persisted-token-123456"})
assert saved.ok
assert db.store.get(_SECURITY_API_TOKEN_KEY) == "persisted-token-123456"
restarted = AppSettings(db)
boot = await restarted.bootstrap_api_token()
assert boot.ok
assert boot.data.get("api_token") == "persisted-token-123456"
@pytest.mark.asyncio
async def test_bootstrap_token_recovers_legacy_hash_only_configuration():
db = _DB()
s = AppSettings(db)
db.store[_SECURITY_API_TOKEN_HASH_KEY] = s._hash_api_token("old-legacy-token-123456")
boot = await s.bootstrap_api_token()
assert boot.ok
assert boot.data.get("api_token")
assert db.store.get(_SECURITY_API_TOKEN_KEY) == boot.data.get("api_token")
assert db.store.get(_SECURITY_API_TOKEN_HASH_KEY) == s._hash_api_token(boot.data.get("api_token"))
@pytest.mark.asyncio
async def test_settings_version_read_get_bump_cache():
db = _DB()
s = AppSettings(db)
db.store[_SETTINGS_VERSION_KEY] = "not-int"
assert await s._read_settings_version() == 0
bump = await s._bump_settings_version_locked()
assert bump.ok and int(bump.data) > 0
v1 = await s._get_settings_version()
v2 = await s._get_settings_version()
assert v1 == v2
@pytest.mark.asyncio
async def test_probe_backend_get_set_paths(monkeypatch):
db = _DB()
s = AppSettings(db)
db.store[_PROBE_BACKEND_KEY] = "invalid"
mode = await s.get_probe_backend()
assert mode in {"auto", "exiftool", "ffprobe", "both"}
bad = await s.set_probe_backend("xxx")
assert bad.code == "INVALID_INPUT"
ok = await s.set_probe_backend("ffprobe")
assert ok.ok and ok.data == "ffprobe"
monkeypatch.setattr(s, "_cache_ttl_s", -1)
assert await s.get_probe_backend() == "ffprobe"
def test_cached_probe_backend_helper_paths():
s = AppSettings(_DB())
cache_key = _PROBE_BACKEND_KEY
# Seed with an expired entry (TTL already passed → monotonic timestamp 0)
s._cache._data[cache_key] = ("auto", 0.0, 1)
assert s._cached_probe_backend(cache_key, 1) == ""
# Seed with fresh timestamp but wrong version → should also miss
s._cache.put(cache_key, "auto", version=1)
assert s._cached_probe_backend(cache_key, 2) == ""
def test_versioned_ttl_cache_evicts_oldest_entry():
s = AppSettings(_DB())
s._cache = s._cache.__class__(ttl_s=s._cache_ttl_s, maxsize=2)
s._cache.put("first", "1", version=0)
s._cache.put("second", "2", version=0)
s._cache.put("third", "3", version=0)
assert s._cache.get("first", version=0) is None
assert s._cache.get("second", version=0) == "2"
assert s._cache.get("third", version=0) == "3"
@pytest.mark.asyncio
async def test_metadata_fallback_get_set_paths():
db = _DB()
s = AppSettings(db)
prefs = await s.get_metadata_fallback_prefs()
assert set(prefs.keys()) == {"image", "media"}
bad = await s.set_metadata_fallback_prefs()
assert bad.code == "INVALID_INPUT"
ok = await s.set_metadata_fallback_prefs(image=False, media=True)
assert ok.ok
assert db.store[_METADATA_FALLBACK_IMAGE_KEY] == "0"
assert db.store[_METADATA_FALLBACK_MEDIA_KEY] == "1"
@pytest.mark.asyncio
async def test_metadata_fallback_db_error():
db = _DB()
s = AppSettings(db)
db.fail_write = True
out = await s.set_metadata_fallback_prefs(image=True)
assert out.code == "DB_ERROR"
@pytest.mark.asyncio
async def test_user_scoped_settings_fallback_and_isolation(monkeypatch):
db = _DB()
s = AppSettings(db)
db.store["allow_write"] = "1"
db.store[_PROBE_BACKEND_KEY] = "ffprobe"
db.store[_METADATA_FALLBACK_IMAGE_KEY] = "0"
db.store[_METADATA_FALLBACK_MEDIA_KEY] = "1"
monkeypatch.delenv("MAJOOR_ALLOW_WRITE", raising=False)
token = sec._CURRENT_USER_ID.set("alice")
try:
assert await s.get_probe_backend() == "ffprobe"
assert await s.get_metadata_fallback_prefs() == {"image": False, "media": True}
assert (await s.get_security_prefs())["allow_write"] is True
probe_res = await s.set_probe_backend("both")
metadata_res = await s.set_metadata_fallback_prefs(image=True, media=False)
prefs_res = await s.set_security_prefs({"allow_write": False})
assert probe_res.ok is True
assert metadata_res.ok is True
assert prefs_res.ok is True
finally:
sec._CURRENT_USER_ID.reset(token)
alice_probe_key = s._storage_key(_PROBE_BACKEND_KEY, user_scoped=True, user_id="alice")
alice_image_key = s._storage_key(_METADATA_FALLBACK_IMAGE_KEY, user_scoped=True, user_id="alice")
alice_media_key = s._storage_key(_METADATA_FALLBACK_MEDIA_KEY, user_scoped=True, user_id="alice")
alice_version_key = s._settings_version_write_key(user_scoped=True, user_id="alice")
assert db.store[_PROBE_BACKEND_KEY] == "ffprobe"
assert db.store[_METADATA_FALLBACK_IMAGE_KEY] == "0"
assert db.store[_METADATA_FALLBACK_MEDIA_KEY] == "1"
assert db.store["allow_write"] == "0"
assert db.store[alice_probe_key] == "both"
assert db.store[alice_image_key] == "1"
assert db.store[alice_media_key] == "0"
assert alice_version_key in db.store
assert os.environ.get("MAJOOR_ALLOW_WRITE") == "0"
token = sec._CURRENT_USER_ID.set("bob")
try:
assert await s.get_probe_backend() == "ffprobe"
assert await s.get_metadata_fallback_prefs() == {"image": False, "media": True}
assert (await s.get_security_prefs())["allow_write"] is False
finally:
sec._CURRENT_USER_ID.reset(token)
def test_metadata_helpers_direct():
s = AppSettings(_DB())
payload = s._normalize_metadata_fallback_write_payload(image="0", media="1")
assert payload[_METADATA_FALLBACK_IMAGE_KEY] is False
assert payload[_METADATA_FALLBACK_MEDIA_KEY] is True
s._cache.put(_METADATA_FALLBACK_IMAGE_KEY, "1", version=0)
s._cache.put(_METADATA_FALLBACK_MEDIA_KEY, "0", version=0)
cur = s._current_metadata_fallback_prefs_from_cache()
assert cur == {"image": True, "media": False}
@pytest.mark.asyncio
async def test_output_directory_set_get_clear(tmp_path: Path, monkeypatch):
db = _DB()
s = AppSettings(db)
target = str(tmp_path)
override_file = tmp_path / "output-override.txt"
class _FP:
output_directory = ""
@staticmethod
def set_output_directory(v):
_FP.output_directory = v
@staticmethod
def get_output_directory():
return "C:/orig"
monkeypatch.setitem(sys.modules, "folder_paths", _FP)
monkeypatch.setattr("mjr_am_backend.settings._OUTPUT_DIR_OVERRIDE_FILE_PATH", override_file)
set_res = await s.set_output_directory(target)
assert set_res.ok
assert db.store[_OUTPUT_DIRECTORY_KEY]
assert override_file.read_text(encoding="utf-8").strip() == str(tmp_path.resolve())
got = await s.get_output_directory()
assert got
clear_res = await s.set_output_directory("")
assert clear_res.ok
assert _OUTPUT_DIRECTORY_KEY not in db.store
assert not override_file.exists()
@pytest.mark.asyncio
async def test_output_directory_db_errors(tmp_path: Path):
db = _DB()
s = AppSettings(db)
db.fail_write = True
out1 = await s.set_output_directory(str(tmp_path))
assert out1.code == "DB_ERROR"
db.fail_write = False
db.fail_delete = True
out2 = await s.set_output_directory("")
assert out2.code == "DB_ERROR"
@pytest.mark.asyncio
async def test_huggingface_token_rejects_reused_security_api_token():
db = _DB()
s = AppSettings(db)
s._runtime_api_token = "mjr_api_secret_123"
blocked = await s.set_huggingface_token("mjr_api_secret_123")
assert blocked.code == "INVALID_INPUT"
assert "huggingface_token" not in db.store
ok = await s.set_huggingface_token("hf_abcdefghijklmnopqrstuvwxyz")
assert ok.ok is True
assert db.store.get("huggingface_token") == "hf_abcdefghijklmnopqrstuvwxyz"
@pytest.mark.asyncio
async def test_ai_verbose_logs_get_set_and_startup_restore(monkeypatch):
db = _DB()
s = AppSettings(db)
assert await s.get_ai_verbose_logs_enabled() is False
out = await s.set_ai_verbose_logs_enabled(True)
assert out.ok is True
assert db.store.get(_AI_VERBOSE_LOGS_KEY) == "1"
assert os.environ.get("MAJOOR_AI_VERBOSE_LOGS") == "1"
monkeypatch.delenv("MAJOOR_AI_VERBOSE_LOGS", raising=False)
monkeypatch.delenv("MJR_AM_AI_VERBOSE_LOGS", raising=False)
monkeypatch.delenv("MAJOOR_VERBOSE_AI_LOGS", raising=False)
monkeypatch.delenv("MJR_AM_VERBOSE_AI_LOGS", raising=False)
await s.apply_ai_verbose_logs_on_startup()
assert os.environ.get("MAJOOR_AI_VERBOSE_LOGS") == "1"
@pytest.mark.asyncio
async def test_route_verbose_logs_get_set_and_startup_restore(monkeypatch):
db = _DB()
s = AppSettings(db)
assert await s.get_route_verbose_logs_enabled() is False
out = await s.set_route_verbose_logs_enabled(True)
assert out.ok is True
assert db.store.get("route_verbose_logs") == "1"
assert os.environ.get("MAJOOR_ROUTE_VERBOSE_LOGS") == "1"
monkeypatch.delenv("MAJOOR_ROUTE_VERBOSE_LOGS", raising=False)
monkeypatch.delenv("MJR_AM_ROUTE_VERBOSE_LOGS", raising=False)
monkeypatch.delenv("MAJOOR_VERBOSE_ROUTE_LOGS", raising=False)
monkeypatch.delenv("MJR_AM_VERBOSE_ROUTE_LOGS", raising=False)
await s.apply_route_verbose_logs_on_startup()
assert os.environ.get("MAJOOR_ROUTE_VERBOSE_LOGS") == "1"
@pytest.mark.asyncio
async def test_startup_verbose_logs_get_set_and_startup_restore(monkeypatch):
db = _DB()
s = AppSettings(db)
assert await s.get_startup_verbose_logs_enabled() is False
out = await s.set_startup_verbose_logs_enabled(True)
assert out.ok is True
assert db.store.get("startup_verbose_logs") == "1"
assert os.environ.get("MAJOOR_STARTUP_VERBOSE_LOGS") == "1"
monkeypatch.delenv("MAJOOR_STARTUP_VERBOSE_LOGS", raising=False)
monkeypatch.delenv("MJR_AM_STARTUP_VERBOSE_LOGS", raising=False)
monkeypatch.delenv("MAJOOR_VERBOSE_STARTUP_LOGS", raising=False)
monkeypatch.delenv("MJR_AM_VERBOSE_STARTUP_LOGS", raising=False)
await s.apply_startup_verbose_logs_on_startup()
assert os.environ.get("MAJOOR_STARTUP_VERBOSE_LOGS") == "1"
def test_output_env_helpers(monkeypatch):
s = AppSettings(_DB())
monkeypatch.setenv("MAJOOR_OUTPUT_DIRECTORY", "x")
monkeypatch.setenv("MJR_AM_OUTPUT_DIRECTORY", "x")
s._clear_output_directory_env_vars()
assert os.environ.get("MAJOOR_OUTPUT_DIRECTORY") is None
monkeypatch.setenv("MAJOOR_ORIGINAL_OUTPUT_DIRECTORY", "C:/orig")
assert s._restore_output_directory_target() == "C:/orig"
s._clear_original_output_directory_env()
assert os.environ.get("MAJOOR_ORIGINAL_OUTPUT_DIRECTORY") is None
def test_apply_comfy_output_directory_and_current(monkeypatch):
s = AppSettings(_DB())
class _FP1:
output_directory = ""
@staticmethod
def set_output_directory(v):
_FP1.output_directory = v
@staticmethod
def get_output_directory():
return "C:/out"
monkeypatch.setitem(sys.modules, "folder_paths", _FP1)
s._apply_comfy_output_directory("C:/new")
assert _FP1.output_directory == "C:/new"
assert s._get_current_comfy_output_directory() == "C:/out"
def test_extract_token_payload_aliases():
s = AppSettings(_DB())
assert s._extract_token_from_prefs_payload({"api_token": "x"}) == "x"
assert s._extract_token_from_prefs_payload({"apiToken": "y"}) == "y"
assert s._extract_token_from_prefs_payload({}) is None
@pytest.mark.asyncio
async def test_ensure_security_bootstrap_and_warn_bump(monkeypatch):
db = _DB()
s = AppSettings(db)
async def _tok():
return "tok"
monkeypatch.setattr(s, "_get_or_create_api_token_locked", _tok)
await s.ensure_security_bootstrap()
async def _bad_bump(*_args, **_kwargs):
return Result.Err("DB_ERROR", "x")
monkeypatch.setattr(s, "_bump_settings_version_locked", _bad_bump)
await s._warn_if_bump_fails("x")