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, _VECTOR_CAPTION_ON_INDEX_KEY, _WORKFLOW_ROOTS_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 prefs["safe_mode"] is False assert prefs["allow_write"] is True assert isinstance(prefs["allow_delete"], bool) assert prefs["allow_delete"] is False assert prefs["allow_rename"] is True assert prefs["allow_open_in_folder"] is True assert prefs["allow_reset_index"] is True 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" @pytest.mark.asyncio async def test_vector_caption_on_index_get_set_and_startup_restore(monkeypatch): db = _DB() s = AppSettings(db) monkeypatch.delenv("MJR_AM_VECTOR_CAPTION_ON_INDEX", raising=False) monkeypatch.delenv("MAJOOR_VECTOR_CAPTION_ON_INDEX", raising=False) assert await s.get_vector_caption_on_index_enabled() is False out = await s.set_vector_caption_on_index_enabled(True) assert out.ok and out.data is True assert db.store.get(_VECTOR_CAPTION_ON_INDEX_KEY) == "1" assert os.environ.get("MJR_AM_VECTOR_CAPTION_ON_INDEX") == "1" monkeypatch.setenv("MJR_AM_VECTOR_CAPTION_ON_INDEX", "0") await s.apply_vector_search_override_on_startup() assert os.environ.get("MJR_AM_VECTOR_CAPTION_ON_INDEX") == "1" 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_workflow_roots_set_get_clear(tmp_path: Path, monkeypatch): db = _DB() s = AppSettings(db) root_a = tmp_path / "wf-a" root_b = tmp_path / "wf-b" out = await s.set_workflow_roots([str(root_a), str(root_b)]) assert out.ok assert out.data == [str(root_a.resolve()), str(root_b.resolve())] assert db.store[_WORKFLOW_ROOTS_KEY] == "\n".join(out.data) assert await s.get_workflow_roots() == out.data assert os.environ["MJR_AM_WORKFLOW_DIRECTORIES"] == os.pathsep.join(out.data) clear = await s.set_workflow_roots("") assert clear.ok assert await s.get_workflow_roots() == [] assert _WORKFLOW_ROOTS_KEY not in db.store assert "MJR_AM_WORKFLOW_DIRECTORIES" not in os.environ @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")