import hashlib import pytest from mjr_am_backend.routes.core.security import _check_write_access, _hash_token TEST_TOKEN = "unit-test-token-01" PBKDF2_FALLBACK_PEPPER = "mjr_api_token_pepper_fallback" def _clear_auth_env(monkeypatch): for key in ( "MAJOOR_API_TOKEN", "MJR_API_TOKEN", "MAJOOR_API_TOKEN_HASH", "MJR_API_TOKEN_HASH", "MAJOOR_API_TOKEN_PEPPER", "MAJOOR_SAFE_MODE", "MAJOOR_ALLOW_WRITE", "MAJOOR_ALLOW_DELETE", "MAJOOR_ALLOW_RENAME", "MAJOOR_ALLOW_OPEN_IN_FOLDER", "MAJOOR_REQUIRE_AUTH", "MAJOOR_ALLOW_REMOTE_WRITE", ): monkeypatch.delenv(key, raising=False) def _pbkdf2_token_hash(token: str, pepper: str = "") -> str: salt = pepper or PBKDF2_FALLBACK_PEPPER return hashlib.pbkdf2_hmac( "sha256", str(token).encode(), salt.encode(), 100_000, ).hex() @pytest.mark.asyncio async def test_write_allowed_on_loopback_when_no_token(monkeypatch): _clear_auth_env(monkeypatch) res = _check_write_access(peer_ip="127.0.0.1", headers={}) assert res.ok, res.error @pytest.mark.asyncio async def test_write_blocked_for_forwarded_remote_when_no_token_by_default(monkeypatch): _clear_auth_env(monkeypatch) # 127.0.0.1 is a trusted proxy by default (see MAJOOR_TRUSTED_PROXIES default), # so X-Forwarded-For should be honored and treated as the true client. res = _check_write_access(peer_ip="127.0.0.1", headers={"X-Forwarded-For": "8.8.8.8"}) assert not res.ok assert res.code == "AUTH_REQUIRED" @pytest.mark.asyncio async def test_write_allows_loopback_even_when_token_configured(monkeypatch): # Loopback is trusted by default regardless of token configuration. # A token is validated if provided, but never required from loopback unless # MAJOOR_REQUIRE_AUTH=1 is explicitly set. _clear_auth_env(monkeypatch) monkeypatch.setenv("MAJOOR_API_TOKEN", TEST_TOKEN) res = _check_write_access(peer_ip="127.0.0.1", headers={}) assert res.ok, res.error assert (res.meta or {}).get("auth") == "loopback" res2 = _check_write_access(peer_ip="127.0.0.1", headers={"X-MJR-Token": TEST_TOKEN}) assert res2.ok, res2.error assert (res2.meta or {}).get("auth") == "token" @pytest.mark.asyncio async def test_write_requires_token_on_loopback_when_require_auth_set(monkeypatch): # MAJOOR_REQUIRE_AUTH=1 forces token auth even for loopback clients. _clear_auth_env(monkeypatch) monkeypatch.setenv("MAJOOR_API_TOKEN", TEST_TOKEN) monkeypatch.setenv("MAJOOR_REQUIRE_AUTH", "1") res = _check_write_access(peer_ip="127.0.0.1", headers={}) assert not res.ok assert res.code == "AUTH_REQUIRED" res2 = _check_write_access(peer_ip="127.0.0.1", headers={"X-MJR-Token": TEST_TOKEN}) assert res2.ok, res2.error @pytest.mark.asyncio async def test_write_token_accepts_bearer_header(monkeypatch): _clear_auth_env(monkeypatch) monkeypatch.setenv("MAJOOR_API_TOKEN", TEST_TOKEN) res = _check_write_access(peer_ip="127.0.0.1", headers={"Authorization": f"Bearer {TEST_TOKEN}"}) assert res.ok, res.error @pytest.mark.asyncio async def test_write_accepts_hashed_token_env(monkeypatch): _clear_auth_env(monkeypatch) token = TEST_TOKEN token_hash = _hash_token(token) monkeypatch.setenv("MAJOOR_API_TOKEN_HASH", token_hash) res = _check_write_access(peer_ip="127.0.0.1", headers={"X-MJR-Token": token}) assert res.ok, res.error assert (res.meta or {}).get("auth") == "token" @pytest.mark.asyncio async def test_write_accepts_pbkdf2_hash_from_settings_service(monkeypatch): _clear_auth_env(monkeypatch) monkeypatch.setenv("MAJOOR_API_TOKEN_HASH", _pbkdf2_token_hash(TEST_TOKEN)) res = _check_write_access( peer_ip="203.0.113.10", headers={"X-MJR-Token": TEST_TOKEN}, request_scheme="https", ) assert res.ok, res.error assert (res.meta or {}).get("auth") == "token" @pytest.mark.asyncio async def test_allow_remote_write_override(monkeypatch): _clear_auth_env(monkeypatch) monkeypatch.setenv("MAJOOR_ALLOW_REMOTE_WRITE", "1") res = _check_write_access(peer_ip="203.0.113.10", headers={}) assert res.ok, res.error @pytest.mark.asyncio async def test_allow_remote_write_override_with_configured_token(monkeypatch): # Regression for GitHub issue #167: an auto-generated API token always # exists after startup, which used to make 'Allow Remote Full Access' # ineffective — remote clients without the token were always blocked. _clear_auth_env(monkeypatch) monkeypatch.setenv("MAJOOR_API_TOKEN", TEST_TOKEN) monkeypatch.setenv("MAJOOR_ALLOW_REMOTE_WRITE", "1") res = _check_write_access(peer_ip="203.0.113.10", headers={}) assert res.ok, res.error assert (res.meta or {}).get("auth") == "allow_remote_no_token" # A matching token still wins (auth=token path). res2 = _check_write_access( peer_ip="203.0.113.10", headers={"X-MJR-Token": TEST_TOKEN}, request_scheme="https", ) assert res2.ok, res2.error assert (res2.meta or {}).get("auth") == "token" @pytest.mark.asyncio async def test_remote_blocked_with_configured_token_when_allow_remote_off(monkeypatch): _clear_auth_env(monkeypatch) monkeypatch.setenv("MAJOOR_API_TOKEN", TEST_TOKEN) res = _check_write_access(peer_ip="203.0.113.10", headers={}) assert not res.ok assert res.code == "AUTH_REQUIRED" @pytest.mark.asyncio async def test_require_auth_still_blocks_remote_despite_allow_remote(monkeypatch): # 'Require Token For All Writes' has priority over 'Allow Remote Full Access'. _clear_auth_env(monkeypatch) monkeypatch.setenv("MAJOOR_API_TOKEN", TEST_TOKEN) monkeypatch.setenv("MAJOOR_ALLOW_REMOTE_WRITE", "1") monkeypatch.setenv("MAJOOR_REQUIRE_AUTH", "1") res = _check_write_access(peer_ip="203.0.113.10", headers={}) assert not res.ok assert res.code == "AUTH_REQUIRED" @pytest.mark.asyncio async def test_require_auth_without_token_blocks(monkeypatch): _clear_auth_env(monkeypatch) monkeypatch.setenv("MAJOOR_REQUIRE_AUTH", "1") res = _check_write_access(peer_ip="127.0.0.1", headers={}) assert not res.ok assert res.code == "AUTH_REQUIRED" @pytest.mark.asyncio async def test_peer_ip_edge_cases_do_not_crash(monkeypatch): _clear_auth_env(monkeypatch) loopback_v6 = _check_write_access(peer_ip="::1", headers={}) assert loopback_v6.ok empty_peer = _check_write_access(peer_ip="", headers={}) assert not empty_peer.ok assert empty_peer.code == "AUTH_REQUIRED" malformed_peer = _check_write_access(peer_ip="not-an-ip", headers={}) assert not malformed_peer.ok assert malformed_peer.code == "AUTH_REQUIRED"