diff --git a/backend/tests/core/test_auth_logger.py b/backend/tests/core/test_auth_logger.py new file mode 100644 index 00000000..89408461 --- /dev/null +++ b/backend/tests/core/test_auth_logger.py @@ -0,0 +1,88 @@ +# #region Test.AuthLogger [C:3] [TYPE Module] [SEMANTICS test,auth,audit,logging,security] +# @BRIEF Tests for core/auth/logger.py — _mask_details, log_security_event. +# @RELATION BINDS_TO -> [AuthLoggerModule] + +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from unittest.mock import patch, MagicMock +import pytest + + +class TestMaskDetails: + """_mask_details — masks sensitive field values.""" + + def test_none_returns_none(self): + from src.core.auth.logger import _mask_details + assert _mask_details(None) is None + + def test_empty_dict(self): + from src.core.auth.logger import _mask_details + assert _mask_details({}) == {} + + def test_password_masked(self): + from src.core.auth.logger import _mask_details + result = _mask_details({"password": "supersecret", "username": "admin"}) + assert result["password"] == "***" + assert result["username"] == "admin" + + def test_token_masked(self): + from src.core.auth.logger import _mask_details + result = _mask_details({"token": "abc123", "action": "login"}) + assert result["token"] == "***" + + def test_api_key_masked(self): + from src.core.auth.logger import _mask_details + result = _mask_details({"api_key": "sk-12345"}) + assert result["api_key"] == "***" + + def test_secret_masked(self): + from src.core.auth.logger import _mask_details + result = _mask_details({"secret": "my-secret"}) + assert result["secret"] == "***" + + def test_authorization_masked(self): + from src.core.auth.logger import _mask_details + result = _mask_details({"authorization": "Bearer xxx"}) + assert result["authorization"] == "***" + + def test_nested_dict_masked(self): + from src.core.auth.logger import _mask_details + result = _mask_details({"user": {"password": "secret", "name": "John"}}) + assert result["user"]["password"] == "***" + assert result["user"]["name"] == "John" + + def test_case_insensitive_masking(self): + from src.core.auth.logger import _mask_details + result = _mask_details({"Password": "secret", "TOKEN": "abc"}) + assert result["Password"] == "***" + assert result["TOKEN"] == "***" + + def test_non_sensitive_keys_preserved(self): + from src.core.auth.logger import _mask_details + result = _mask_details({"email": "user@example.com", "role": "admin"}) + assert result["email"] == "user@example.com" + assert result["role"] == "admin" + + +class TestLogSecurityEvent: + """log_security_event — logs audit events with masked details.""" + + def test_logs_without_details(self): + from src.core.auth.logger import log_security_event + with patch("src.core.auth.logger.logger.info") as mock_info: + log_security_event("LOGIN_SUCCESS", "admin") + assert mock_info.call_count >= 1 + + def test_logs_with_masked_details(self): + from src.core.auth.logger import log_security_event + with patch("src.core.auth.logger.logger.info") as mock_info: + log_security_event("LOGIN_FAILED", "admin", {"password": "wrong", "ip": "10.0.0.1"}) + assert mock_info.call_count >= 1 + # The details should contain masked password + args_list = [c[0] for c in mock_info.call_args_list] + details_call = [a for a in args_list if "Details" in a[0]] + assert len(details_call) >= 1 +# #endregion Test.AuthLogger diff --git a/backend/tests/core/test_client_registry.py b/backend/tests/core/test_client_registry.py new file mode 100644 index 00000000..b5854979 --- /dev/null +++ b/backend/tests/core/test_client_registry.py @@ -0,0 +1,254 @@ +# #region Test.SupersetClientRegistry [C:3] [TYPE Module] [SEMANTICS test,registry,superset,client,async] +# @BRIEF Tests for core/utils/client_registry.py — get_client, get_superset_client, get_semaphore, get_auth_lock, shutdown. +# @RELATION BINDS_TO -> [SupersetClientRegistryModule] + +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from unittest.mock import AsyncMock, MagicMock, patch +import asyncio +import pytest + + +class TestBuildEnvId: + """_build_env_id — stable identifier from env config.""" + + def test_env_with_id(self): + from src.core.utils.client_registry import _build_env_id + env = MagicMock() + env.id = "env-1" + env.base_url = "http://superset:8088" + result = _build_env_id(env) + assert "env-1" in result + assert "superset" in result + + def test_env_without_id_uses_object_id(self): + from src.core.utils.client_registry import _build_env_id + env = {"base_url": "http://x.com", "username": "admin"} + result = _build_env_id(env) + assert "x.com" in result + assert "admin" in result + + def test_dict_env_fallback(self): + from src.core.utils.client_registry import _build_env_id + env = {"base_url": "http://test:8080", "username": "user"} + result = _build_env_id(env) + assert result + + +@pytest.mark.asyncio +class TestGetClient: + """get_client — create or return cached client.""" + + async def test_creates_new_client_from_model(self): + from src.core.utils.client_registry import get_client, shutdown + + env = MagicMock() + env.id = "env-1" + env.url = "http://superset:8088" + env.username = "admin" + env.password = "pass" + env.verify_ssl = False + env.timeout = 30 + + with patch("src.core.utils.client_registry.AsyncAPIClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client_cls.return_value = mock_client + + client = await get_client(env) + assert client is mock_client + mock_client_cls.assert_called_once() + + await shutdown() + + async def test_returns_cached_client(self): + from src.core.utils.client_registry import get_client, shutdown, _registry + + env = MagicMock() + env.id = "env-1" + env.url = "http://superset:8088" + env.username = "admin" + env.password = "pass" + env.verify_ssl = False + env.timeout = 30 + + with patch("src.core.utils.client_registry.AsyncAPIClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client_cls.return_value = mock_client + + client1 = await get_client(env) + client2 = await get_client(env) + assert client1 is client2 + assert mock_client_cls.call_count == 1 + + await shutdown() + + async def test_dict_env(self): + from src.core.utils.client_registry import get_client, shutdown + + env = {"base_url": "http://superset:8088", "username": "admin", "password": "pass"} + + with patch("src.core.utils.client_registry.AsyncAPIClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client_cls.return_value = mock_client + + client = await get_client(env) + assert client is mock_client + + await shutdown() + + async def test_unknown_env_type(self): + from src.core.utils.client_registry import get_client, shutdown + + env = 42 # not a model or dict + + with patch("src.core.utils.client_registry.AsyncAPIClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client_cls.return_value = mock_client + + client = await get_client(env) + assert client is mock_client + + await shutdown() + + +@pytest.mark.asyncio +class TestGetSupersetClient: + """get_superset_client — wraps AsyncAPIClient in SupersetClient.""" + + async def test_returns_superset_client(self): + from src.core.utils.client_registry import get_superset_client, shutdown + + env = MagicMock() + env.id = "env-1" + env.url = "http://superset:8088" + env.username = "admin" + env.password = "pass" + env.verify_ssl = False + env.timeout = 30 + + mock_shared = AsyncMock() + + with ( + patch("src.core.utils.client_registry.get_client", return_value=mock_shared), + patch("src.core.superset_client.SupersetClient") as mock_sc_cls, + ): + mock_sc = MagicMock() + mock_sc_cls.return_value = mock_sc + + client = await get_superset_client(env) + assert client is mock_sc + + await shutdown() + + +@pytest.mark.asyncio +class TestGetSemaphoreAndLock: + """get_semaphore and get_auth_lock — return shared resources.""" + + async def test_get_semaphore(self): + from src.core.utils.client_registry import get_semaphore, get_client, shutdown + + env = MagicMock() + env.id = "env-1" + env.url = "http://superset:8088" + env.username = "admin" + env.password = "pass" + env.verify_ssl = False + env.timeout = 30 + + with patch("src.core.utils.client_registry.AsyncAPIClient") as mock_cls: + mock_cls.return_value = AsyncMock() + await get_client(env) + sem = await get_semaphore(env) + assert sem is not None + + await shutdown() + + async def test_get_auth_lock(self): + from src.core.utils.client_registry import get_auth_lock, get_client, shutdown + + env = MagicMock() + env.id = "env-1" + env.url = "http://superset:8088" + env.username = "admin" + env.password = "pass" + env.verify_ssl = False + env.timeout = 30 + + with patch("src.core.utils.client_registry.AsyncAPIClient") as mock_cls: + mock_cls.return_value = AsyncMock() + await get_client(env) + lock = await get_auth_lock(env) + assert lock is not None + + await shutdown() + + async def test_get_semaphore_creates_client_if_missing(self): + from src.core.utils.client_registry import get_semaphore, shutdown, _registry + + env = MagicMock() + env.id = "env-1" + env.url = "http://superset:8088" + env.username = "admin" + env.password = "pass" + env.verify_ssl = False + env.timeout = 30 + + _registry.clear() + + with patch("src.core.utils.client_registry.AsyncAPIClient") as mock_cls: + mock_cls.return_value = AsyncMock() + sem = await get_semaphore(env) + assert sem is not None + + await shutdown() + + +@pytest.mark.asyncio +class TestShutdown: + """shutdown — close all clients and clear registry.""" + + async def test_shutdown_closes_clients(self): + from src.core.utils.client_registry import get_client, shutdown + + env = MagicMock() + env.id = "env-1" + env.url = "http://superset:8088" + env.username = "admin" + env.password = "pass" + env.verify_ssl = False + env.timeout = 30 + + with patch("src.core.utils.client_registry.AsyncAPIClient") as mock_cls: + mock_client = AsyncMock() + mock_cls.return_value = mock_client + await get_client(env) + await shutdown() + mock_client.aclose.assert_called_once() + + async def test_shutdown_empty_registry(self): + from src.core.utils.client_registry import shutdown, _registry + _registry.clear() + await shutdown() # should not raise + + async def test_shutdown_handles_close_error(self): + from src.core.utils.client_registry import get_client, shutdown + + env = MagicMock() + env.id = "env-1" + env.url = "http://superset:8088" + env.username = "admin" + env.password = "pass" + env.verify_ssl = False + env.timeout = 30 + + with patch("src.core.utils.client_registry.AsyncAPIClient") as mock_cls: + mock_client = AsyncMock() + mock_client.aclose.side_effect = ConnectionError("Close failed") + mock_cls.return_value = mock_client + await get_client(env) + await shutdown() # should not raise +# #endregion Test.SupersetClientRegistry diff --git a/backend/tests/core/test_cot_logger.py b/backend/tests/core/test_cot_logger.py new file mode 100644 index 00000000..73638879 --- /dev/null +++ b/backend/tests/core/test_cot_logger.py @@ -0,0 +1,199 @@ +# #region Test.CotLogger [C:3] [TYPE Module] [SEMANTICS test,logging,cot,trace,span,json] +# @BRIEF Tests for core/cot_logger.py — trace_id, span, log markers, MarkerLogger. +# @RELATION BINDS_TO -> [CotLoggerModule] + +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +import logging +import re +from unittest.mock import patch, MagicMock, call +import pytest + + +class TestSeedTraceId: + """seed_trace_id — generates UUID and sets ContextVar.""" + + def test_returns_uuid_string(self): + from src.core.cot_logger import seed_trace_id + tid = seed_trace_id() + assert isinstance(tid, str) + assert len(tid) == 36 # UUID4 with hyphens + assert tid.count("-") == 4 + + def test_sets_trace_id(self): + from src.core.cot_logger import seed_trace_id, get_trace_id + tid = seed_trace_id() + assert get_trace_id() == tid + + +class TestSetTraceId: + """set_trace_id — sets explicit trace ID.""" + + def test_sets_provided_id(self): + from src.core.cot_logger import set_trace_id, get_trace_id + set_trace_id("custom-trace-123") + assert get_trace_id() == "custom-trace-123" + + +class TestGetTraceId: + """get_trace_id — returns current trace ID.""" + + def test_default_empty(self): + from src.core.cot_logger import get_trace_id + # After seed, get_trace_id returns non-empty + # In a fresh test the ContextVar may be empty + pass # ContextVar has default "" + + def test_after_seed_returns_value(self): + from src.core.cot_logger import seed_trace_id, get_trace_id + tid = seed_trace_id() + assert get_trace_id() == tid + + +class TestPushPopSpan: + """push_span and pop_span — span stack operations.""" + + def test_push_returns_previous(self): + from src.core.cot_logger import push_span, pop_span + prev1 = push_span("span-1") + # Initially empty + prev2 = push_span("span-2") + assert prev2 == "span-1" + pop_span(prev2) + # After pop, current span is back to "span-1" + + def test_push_pop_restores(self): + from src.core.cot_logger import push_span + prev = push_span("outer") + inner_prev = push_span("inner") + assert inner_prev == "outer" + # pop restores outer + from src.core.cot_logger import pop_span + pop_span(inner_prev) + + +class TestLog: + """log — structured JSON logging with markers.""" + + def test_reason_marker_info_level(self): + from src.core.cot_logger import log + with patch("src.core.cot_logger.cot_logger.info") as mock_info: + log("TestModule", "REASON", "Testing reason") + mock_info.assert_called_once() + args, kwargs = mock_info.call_args + assert "Testing reason" in args[0] + extra = kwargs.get("extra", {}) + assert extra["marker"] == "REASON" + assert extra["src"] == "TestModule" + assert extra["intent"] == "Testing reason" + + def test_reflect_marker_info_level(self): + from src.core.cot_logger import log + with patch("src.core.cot_logger.cot_logger.info") as mock_info: + log("Test", "REFLECT", "Reflecting") + mock_info.assert_called_once() + + def test_explore_marker_warning_level(self): + from src.core.cot_logger import log + with patch("src.core.cot_logger.cot_logger.warning") as mock_warn: + log("Test", "EXPLORE", "Exploring alternatives", error="Assumption violated") + mock_warn.assert_called_once() + + def test_error_level_explicit(self): + from src.core.cot_logger import log + with patch("src.core.cot_logger.cot_logger.error") as mock_err: + log("Test", "REASON", "Error test", level="ERROR") + mock_err.assert_called_once() + + def test_debug_level_explicit(self): + from src.core.cot_logger import log + with patch("src.core.cot_logger.cot_logger.debug") as mock_debug: + log("Test", "REASON", "Debug test", level="DEBUG") + mock_debug.assert_called_once() + + def test_payload_included(self): + from src.core.cot_logger import log + with patch("src.core.cot_logger.cot_logger.info") as mock_info: + log("Test", "REASON", "With payload", payload={"key": "value"}) + extra = mock_info.call_args[1]["extra"] + assert extra["payload"] == {"key": "value"} + + def test_error_included(self): + from src.core.cot_logger import log + with patch("src.core.cot_logger.cot_logger.warning") as mock_warn: + log("Test", "EXPLORE", "Error case", error="Something broke") + extra = mock_warn.call_args[1]["extra"] + assert extra["error"] == "Something broke" + + def test_payload_not_included_when_none(self): + from src.core.cot_logger import log + with patch("src.core.cot_logger.cot_logger.info") as mock_info: + log("Test", "REASON", "No payload") + extra = mock_info.call_args[1]["extra"] + assert "payload" not in extra + + def test_error_not_included_when_none(self): + from src.core.cot_logger import log + with patch("src.core.cot_logger.cot_logger.info") as mock_info: + log("Test", "REASON", "No error") + extra = mock_info.call_args[1]["extra"] + assert "error" not in extra + + def test_warning_level_inferred_from_explore(self): + from src.core.cot_logger import log + with patch("src.core.cot_logger.cot_logger.warning") as mock_warn: + log("Test", "EXPLORE", "Auto warning") + mock_warn.assert_called_once() + + +class TestMarkerLogger: + """MarkerLogger — convenience proxy for .reason(), .reflect(), .explore().""" + + @pytest.fixture + def logger(self): + from src.core.cot_logger import MarkerLogger + return MarkerLogger("MyComponent") + + def test_reason(self, logger): + with patch("src.core.cot_logger.log") as mock_log: + logger.reason("Doing something", payload={"action": "test"}) + mock_log.assert_called_once_with("MyComponent", "REASON", "Doing something", payload={"action": "test"}) + + def test_reflect(self, logger): + with patch("src.core.cot_logger.log") as mock_log: + logger.reflect("Done") + mock_log.assert_called_once_with("MyComponent", "REFLECT", "Done", payload=None) + + def test_explore(self, logger): + with patch("src.core.cot_logger.log") as mock_log: + logger.explore("Failed", error="DB timeout") + mock_log.assert_called_once_with("MyComponent", "EXPLORE", "Failed", payload=None, error="DB timeout") + + def test_explore_with_payload_and_error(self, logger): + with patch("src.core.cot_logger.log") as mock_log: + logger.explore("Retrying", payload={"attempt": 2}, error="Timeout") + mock_log.assert_called_once_with("MyComponent", "EXPLORE", "Retrying", payload={"attempt": 2}, error="Timeout") + + +class TestTraceIdPropagation: + """Trace ID propagation across async contexts.""" + + def test_seed_then_get(self): + from src.core.cot_logger import seed_trace_id, get_trace_id + tid = seed_trace_id() + assert get_trace_id() == tid + + def test_set_then_get(self): + from src.core.cot_logger import set_trace_id, get_trace_id + set_trace_id("abc-123") + assert get_trace_id() == "abc-123" + + def test_set_overrides_seed(self): + from src.core.cot_logger import seed_trace_id, set_trace_id, get_trace_id + seed_trace_id() + set_trace_id("override") + assert get_trace_id() == "override" +# #endregion Test.CotLogger diff --git a/backend/tests/core/test_encryption.py b/backend/tests/core/test_encryption.py new file mode 100644 index 00000000..f873f20e --- /dev/null +++ b/backend/tests/core/test_encryption.py @@ -0,0 +1,112 @@ +# #region Test.Encryption [C:3] [TYPE Module] [SEMANTICS test,encryption,fernet,key,crypto] +# @BRIEF Tests for core/encryption.py and core/encryption_key.py — Fernet key loading, encrypt/decrypt. +# @RELATION BINDS_TO -> [EncryptionCore] + +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +import os +import tempfile +from unittest.mock import patch, MagicMock +import pytest + + +class TestRequireFernetKey: + """_require_fernet_key — loads and validates Fernet key from environment.""" + + def test_valid_key(self): + from src.core.encryption import _require_fernet_key + key = "MldUHg5kwSAcPnnYxmhWDS6ASb6e_bWQRV5gtwHrjQ0=" + with patch.dict(os.environ, {"ENCRYPTION_KEY": key}, clear=True): + result = _require_fernet_key() + assert result == key.encode() + + def test_missing_key_raises(self): + from src.core.encryption import _require_fernet_key + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(RuntimeError, match="ENCRYPTION_KEY must be set"): + _require_fernet_key() + + def test_invalid_key_raises(self): + from src.core.encryption import _require_fernet_key + with patch.dict(os.environ, {"ENCRYPTION_KEY": "not-a-valid-fernet-key"}, clear=True): + with pytest.raises(RuntimeError, match="valid Fernet key"): + _require_fernet_key() + + def test_empty_key_raises(self): + from src.core.encryption import _require_fernet_key + with patch.dict(os.environ, {"ENCRYPTION_KEY": " "}, clear=True): + with pytest.raises(RuntimeError, match="ENCRYPTION_KEY must be set"): + _require_fernet_key() + + +class TestEncryptionManager: + """EncryptionManager — encrypt and decrypt operations.""" + + def test_encrypt_decrypt_cycle(self): + from src.core.encryption import EncryptionManager + key = "MldUHg5kwSAcPnnYxmhWDS6ASb6e_bWQRV5gtwHrjQ0=" + with patch.dict(os.environ, {"ENCRYPTION_KEY": key}, clear=True): + mgr = EncryptionManager() + encrypted = mgr.encrypt("my_secret_data") + assert encrypted != "my_secret_data" + decrypted = mgr.decrypt(encrypted) + assert decrypted == "my_secret_data" + + def test_empty_string_encryption(self): + from src.core.encryption import EncryptionManager + key = "MldUHg5kwSAcPnnYxmhWDS6ASb6e_bWQRV5gtwHrjQ0=" + with patch.dict(os.environ, {"ENCRYPTION_KEY": key}, clear=True): + mgr = EncryptionManager() + encrypted = mgr.encrypt("") + decrypted = mgr.decrypt(encrypted) + assert decrypted == "" + + def test_decrypt_invalid_data_raises(self): + from src.core.encryption import EncryptionManager + key = "MldUHg5kwSAcPnnYxmhWDS6ASb6e_bWQRV5gtwHrjQ0=" + with patch.dict(os.environ, {"ENCRYPTION_KEY": key}, clear=True): + mgr = EncryptionManager() + with pytest.raises(Exception): + mgr.decrypt("not-encrypted-data") + + +class TestEnsureEncryptionKey: + """ensure_encryption_key — resolve key from env or .env file.""" + + def test_from_environment(self): + from src.core.encryption_key import ensure_encryption_key + key = "MldUHg5kwSAcPnnYxmhWDS6ASb6e_bWQRV5gtwHrjQ0=" + with patch.dict(os.environ, {"ENCRYPTION_KEY": key}, clear=True): + result = ensure_encryption_key() + assert result == key + + def test_from_env_file(self): + from src.core.encryption_key import ensure_encryption_key + key = "MldUHg5kwSAcPnnYxmhWDS6ASb6e_bWQRV5gtwHrjQ0=" + with patch.dict(os.environ, {}, clear=True): + with tempfile.TemporaryDirectory() as tmpdir: + env_file = Path(tmpdir) / ".env" + env_file.write_text(f"ENCRYPTION_KEY={key}\nOTHER=value\n") + result = ensure_encryption_key(env_file) + assert result == key + # Should also set it in environment + assert os.environ.get("ENCRYPTION_KEY") == key + + def test_env_file_not_found_raises(self): + from src.core.encryption_key import ensure_encryption_key + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(RuntimeError, match="ENCRYPTION_KEY is not set"): + ensure_encryption_key(Path("/nonexistent/.env")) + + def test_env_file_without_key_raises(self): + from src.core.encryption_key import ensure_encryption_key + with patch.dict(os.environ, {}, clear=True): + with tempfile.TemporaryDirectory() as tmpdir: + env_file = Path(tmpdir) / ".env" + env_file.write_text("OTHER=value\n") + with pytest.raises(RuntimeError, match="ENCRYPTION_KEY is not set"): + ensure_encryption_key(env_file) +# #endregion Test.Encryption diff --git a/backend/tests/core/test_executors.py b/backend/tests/core/test_executors.py new file mode 100644 index 00000000..257dcaf4 --- /dev/null +++ b/backend/tests/core/test_executors.py @@ -0,0 +1,187 @@ +# #region Test.BlockingExecutors [C:3] [TYPE Module] [SEMANTICS test,async,executors,blocking,threadpool] +# @BRIEF Tests for core/utils/executors.py — init, shutdown, run_blocking, run_cpu_blocking. +# @RELATION BINDS_TO -> [BlockingExecutorsModule] + +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +import unittest +from unittest.mock import MagicMock, patch, AsyncMock +import asyncio +import pytest + + +class TestInitShutdown: + """init_executors and shutdown_executors.""" + + def test_init_creates_executors(self): + from src.core.utils.executors import init_executors + with patch("src.core.utils.executors.ThreadPoolExecutor") as mock_tpe: + init_executors(db_workers=5, file_workers=3, git_workers=2) + assert mock_tpe.call_count == 3 + + def test_shutdown_without_init(self): + # shutdown should not raise when executors are None + from src.core.utils.executors import shutdown_executors + shutdown_executors() + + def test_shutdown_calls_shutdown(self): + from src.core.utils.executors import init_executors, shutdown_executors + import src.core.utils.executors as exec_mod + with patch.object(exec_mod, "_db_executor", None), \ + patch.object(exec_mod, "_file_executor", None), \ + patch.object(exec_mod, "_git_executor", None): + with patch("src.core.utils.executors.ThreadPoolExecutor") as mock_tpe: + mock_executor = MagicMock() + mock_tpe.return_value = mock_executor + init_executors() + shutdown_executors() + assert mock_executor.shutdown.call_count >= 1 + for call in mock_executor.shutdown.call_args_list: + assert call == unittest.mock.call(wait=True, cancel_futures=True) + + def test_shutdown_no_wait(self): + from src.core.utils.executors import init_executors, shutdown_executors + import src.core.utils.executors as exec_mod + with patch.object(exec_mod, "_db_executor", None), \ + patch.object(exec_mod, "_file_executor", None), \ + patch.object(exec_mod, "_git_executor", None): + with patch("src.core.utils.executors.ThreadPoolExecutor") as mock_tpe: + mock_executor = MagicMock() + mock_tpe.return_value = mock_executor + init_executors() + shutdown_executors(wait=False, cancel_futures=False) + assert mock_executor.shutdown.call_count >= 1 + for call in mock_executor.shutdown.call_args_list: + assert call == unittest.mock.call(wait=False, cancel_futures=False) + + +class TestGetExecutor: + """_get_executor — returns executor and semaphore by kind.""" + + def test_db_kind(self): + from src.core.utils.executors import _get_executor, init_executors, shutdown_executors + with patch("src.core.utils.executors.ThreadPoolExecutor"): + init_executors() + executor, sem = _get_executor("db") + assert executor is not None + assert sem is not None + shutdown_executors() + + def test_file_kind(self): + from src.core.utils.executors import _get_executor, init_executors, shutdown_executors + with patch("src.core.utils.executors.ThreadPoolExecutor"): + init_executors() + executor, sem = _get_executor("file") + assert executor is not None + assert sem is not None + shutdown_executors() + + def test_git_kind(self): + from src.core.utils.executors import _get_executor, init_executors, shutdown_executors + with patch("src.core.utils.executors.ThreadPoolExecutor"): + init_executors() + executor, sem = _get_executor("git") + assert executor is not None + assert sem is not None + shutdown_executors() + + def test_unknown_kind_raises(self): + from src.core.utils.executors import _get_executor, init_executors, shutdown_executors + with patch("src.core.utils.executors.ThreadPoolExecutor"): + init_executors() + with pytest.raises(ValueError, match="Unknown executor kind"): + _get_executor("unknown") + shutdown_executors() + + +class TestRunBlocking: + """run_blocking — execute blocking function in named executor.""" + + @pytest.mark.asyncio + async def test_runs_function(self): + from src.core.utils.executors import run_blocking, init_executors, shutdown_executors + + with patch("src.core.utils.executors.ThreadPoolExecutor") as mock_tpe: + mock_executor = MagicMock() + mock_tpe.return_value = mock_executor + init_executors() + + # Make run_in_executor work + mock_loop = AsyncMock() + mock_loop.run_in_executor.return_value = "result" + get_running_loop = asyncio.get_running_loop + + with patch.object(asyncio, "get_running_loop", return_value=mock_loop): + result = await run_blocking("db", lambda: "hello") + assert result == "result" + + shutdown_executors() + + @pytest.mark.asyncio + async def test_semaphore_acquisition_failure(self): + from src.core.utils.executors import run_blocking, init_executors, shutdown_executors + + with patch("src.core.utils.executors.ThreadPoolExecutor"): + init_executors(queue_timeout=0.1) + + with ( + patch("src.core.utils.executors._db_semaphore") as mock_sem, + pytest.raises(TimeoutError, match="queue timeout"), + ): + mock_sem.acquire.side_effect = TimeoutError() + await run_blocking("db", lambda: "hello", timeout=0.1) + + shutdown_executors() + + @pytest.mark.asyncio + async def test_semaphore_release_on_success(self): + from src.core.utils.executors import run_blocking, init_executors, shutdown_executors + + with patch("src.core.utils.executors.ThreadPoolExecutor"): + init_executors() + mock_loop = AsyncMock() + mock_loop.run_in_executor.return_value = "done" + + # Track semaphore acquire/release + with patch.object(asyncio, "get_running_loop", return_value=mock_loop): + result = await run_blocking("file", lambda: "work") + assert result == "done" + + shutdown_executors() + + @pytest.mark.asyncio + async def test_cancelled_error_propagates(self): + from src.core.utils.executors import run_blocking, init_executors, shutdown_executors + + with patch("src.core.utils.executors.ThreadPoolExecutor"): + init_executors() + mock_loop = AsyncMock() + mock_loop.run_in_executor.side_effect = asyncio.CancelledError() + + with ( + patch.object(asyncio, "get_running_loop", return_value=mock_loop), + pytest.raises(asyncio.CancelledError), + ): + await run_blocking("git", lambda: "fail") + + shutdown_executors() + + +class TestRunCpuBlocking: + """run_cpu_blocking — CPU-bound work in default executor.""" + + @pytest.mark.asyncio + async def test_runs_in_default_executor(self): + from src.core.utils.executors import run_cpu_blocking + + mock_loop = AsyncMock() + mock_loop.run_in_executor.return_value = 42 + + with patch.object(asyncio, "get_running_loop", return_value=mock_loop): + result = await run_cpu_blocking(lambda: 42) + assert result == 42 + mock_loop.run_in_executor.assert_called_once_with(None, mock_loop.run_in_executor.call_args[0][1]) +# #endregion Test.BlockingExecutors diff --git a/backend/tests/core/test_fileio_utils.py b/backend/tests/core/test_fileio_utils.py new file mode 100644 index 00000000..3a8a72be --- /dev/null +++ b/backend/tests/core/test_fileio_utils.py @@ -0,0 +1,310 @@ +# #region Test.FileIO.Utils [C:3] [TYPE Module] [SEMANTICS test,fileio,utils,filename,sanitize,crc32] +# @BRIEF Tests for core/utils/fileio.py — pure utility functions: sanitize_filename, get_filename_from_headers, calculate_crc32, create_temp_file, remove_empty_directories. +# @RELATION BINDS_TO -> [FileIO] + +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +import os +import tempfile +import zipfile +from unittest.mock import MagicMock, patch +import pytest + + +class TestSanitizeFilename: + """sanitize_filename — removes invalid filename characters.""" + + def test_already_clean(self): + from src.core.utils.fileio import sanitize_filename + assert sanitize_filename("dashboard_export.zip") == "dashboard_export.zip" + + def test_replaces_backslash(self): + from src.core.utils.fileio import sanitize_filename + assert "\\" not in sanitize_filename("a\\b") + assert "_" in sanitize_filename("a\\b") + + def test_replaces_colon(self): + from src.core.utils.fileio import sanitize_filename + assert ":" not in sanitize_filename("a:b") + assert "_" in sanitize_filename("a:b") + + def test_replaces_angle_brackets(self): + from src.core.utils.fileio import sanitize_filename + assert "<" not in sanitize_filename("a" not in sanitize_filename("a>b") + + def test_replaces_question_mark(self): + from src.core.utils.fileio import sanitize_filename + assert "?" not in sanitize_filename("a?b") + assert "_" in sanitize_filename("a?b") + + def test_replaces_asterisk(self): + from src.core.utils.fileio import sanitize_filename + assert "*" not in sanitize_filename("a*b") + assert "_" in sanitize_filename("a*b") + + def test_replaces_pipe(self): + from src.core.utils.fileio import sanitize_filename + assert "|" not in sanitize_filename("a|b") + assert "_" in sanitize_filename("a|b") + + def test_replaces_quote(self): + from src.core.utils.fileio import sanitize_filename + assert '"' not in sanitize_filename('a"b') + assert "_" in sanitize_filename('a"b') + + def test_strips_whitespace(self): + from src.core.utils.fileio import sanitize_filename + result = sanitize_filename(" file.txt ") + assert result == "file.txt" + assert result == result.strip() + + def test_empty_string(self): + from src.core.utils.fileio import sanitize_filename + assert sanitize_filename("") == "" + + +class TestGetFilenameFromHeaders: + """get_filename_from_headers — extracts filename from Content-Disposition.""" + + def test_standard_header(self): + from src.core.utils.fileio import get_filename_from_headers + headers = {"Content-Disposition": 'attachment; filename="report.pdf"'} + assert get_filename_from_headers(headers) == "report.pdf" + + def test_header_without_quotes(self): + from src.core.utils.fileio import get_filename_from_headers + headers = {"Content-Disposition": "attachment; filename=report.pdf"} + assert get_filename_from_headers(headers) == "report.pdf" + + def test_missing_header(self): + from src.core.utils.fileio import get_filename_from_headers + assert get_filename_from_headers({}) is None + + def test_empty_header(self): + from src.core.utils.fileio import get_filename_from_headers + headers = {"Content-Disposition": ""} + assert get_filename_from_headers(headers) is None + + def test_no_filename_in_header(self): + from src.core.utils.fileio import get_filename_from_headers + headers = {"Content-Disposition": "inline"} + assert get_filename_from_headers(headers) is None + + def test_filename_with_spaces(self): + from src.core.utils.fileio import get_filename_from_headers + headers = {"Content-Disposition": 'attachment; filename="my report.csv"'} + assert get_filename_from_headers(headers) == "my report.csv" + + def test_utf8_filename_with_regular_format(self): + """The regex only matches filename=\"...\", not filename*=UTF-8''...""" + from src.core.utils.fileio import get_filename_from_headers + headers = {"Content-Disposition": 'attachment; filename="report.pdf"; filename*=UTF-8\'\'%D0%BE%D1%82%D1%87%D0%B5%D1%82.pdf'} + result = get_filename_from_headers(headers) + assert result == "report.pdf" + + +class TestCalculateCrc32: + """calculate_crc32 — computes CRC32 hash of a file.""" + + def test_known_content(self): + import zlib + from src.core.utils.fileio import calculate_crc32 + with tempfile.NamedTemporaryFile(delete=False) as f: + f.write(b"hello world") + f.flush() + fname = f.name + try: + crc = calculate_crc32(fname) + assert isinstance(crc, str) + assert len(crc) == 8 # 32-bit hex, 8 chars + expected = format(zlib.crc32(b"hello world") & 0xFFFFFFFF, "08x") + assert crc == expected + finally: + os.unlink(fname) + + def test_empty_file(self): + from src.core.utils.fileio import calculate_crc32 + with tempfile.NamedTemporaryFile(delete=False) as f: + fname = f.name + try: + crc = calculate_crc32(fname) + assert isinstance(crc, str) + assert len(crc) == 8 + finally: + os.unlink(fname) + + +class TestCreateTempFile: + """create_temp_file — context manager for temp resources.""" + + def test_creates_and_cleans_up_file(self): + from src.core.utils.fileio import create_temp_file + path = None + with create_temp_file(content=b"test content", suffix=".zip") as p: + path = p + assert path.exists() + assert path.suffix == ".zip" + assert path.read_bytes() == b"test content" + assert not path.exists() + + def test_no_content(self): + from src.core.utils.fileio import create_temp_file + with create_temp_file(suffix=".zip") as p: + assert p.exists() + assert p.stat().st_size == 0 + + def test_directory_mode(self): + from src.core.utils.fileio import create_temp_file + with create_temp_file(suffix=".dir") as p: + assert p.is_dir() + (p / "test.txt").write_text("hello") + assert (p / "test.txt").exists() + + def test_dry_run_no_write(self): + from src.core.utils.fileio import create_temp_file + with create_temp_file(content=b"data", suffix=".zip", dry_run=True) as p: + # In dry_run mode, no actual file is created + pass + + def test_exception_cleans_up(self): + from src.core.utils.fileio import create_temp_file + path = None + try: + with create_temp_file(content=b"data", suffix=".txt") as p: + path = p + raise ValueError("test error") + except ValueError: + pass + assert path is None or not path.exists() + + +class TestRemoveEmptyDirectories: + """remove_empty_directories — removes empty dirs recursively.""" + + def test_removes_empty_dirs(self): + from src.core.utils.fileio import remove_empty_directories + with tempfile.TemporaryDirectory() as tmpdir: + empty_dir = Path(tmpdir) / "empty" + empty_dir.mkdir() + nested_empty = empty_dir / "nested" + nested_empty.mkdir() + # Non-empty dir should remain + non_empty = Path(tmpdir) / "non_empty" + non_empty.mkdir() + (non_empty / "file.txt").write_text("data") + + removed = remove_empty_directories(tmpdir) + assert removed >= 2 # at least 2 empty dirs removed + assert non_empty.exists() + assert not empty_dir.exists() + + def test_no_empty_dirs(self): + from src.core.utils.fileio import remove_empty_directories + with tempfile.TemporaryDirectory() as tmpdir: + d = Path(tmpdir) / "dir" + d.mkdir() + (d / "file.txt").write_text("data") + removed = remove_empty_directories(tmpdir) + assert removed == 0 + + +class TestCreateDashboardExport: + """create_dashboard_export — packs files into ZIP.""" + + def test_creates_zip_with_files(self): + from src.core.utils.fileio import create_dashboard_export + with tempfile.TemporaryDirectory() as tmpdir: + src = Path(tmpdir) / "src" + src.mkdir() + (src / "dashboard.json").write_text('{"key": "value"}') + (src / "metadata.yaml").write_text("version: 1") + + zip_path = Path(tmpdir) / "export.zip" + result = create_dashboard_export(zip_path, [str(src)]) + assert result is True + assert zip_path.exists() + + # Verify contents + with zipfile.ZipFile(zip_path, "r") as zf: + names = zf.namelist() + assert any("dashboard.json" in n for n in names) + + def test_excludes_extensions(self): + from src.core.utils.fileio import create_dashboard_export + with tempfile.TemporaryDirectory() as tmpdir: + src = Path(tmpdir) / "src" + src.mkdir() + (src / "file.json").write_text("{}") + (src / "file.log").write_text("log data") + + zip_path = Path(tmpdir) / "export.zip" + result = create_dashboard_export(zip_path, [str(src)], exclude_extensions=[".log"]) + assert result is True + + with zipfile.ZipFile(zip_path, "r") as zf: + names = zf.namelist() + assert any("file.json" in n for n in names) + assert not any("file.log" in n for n in names) + + def test_source_not_found(self): + from src.core.utils.fileio import create_dashboard_export + result = create_dashboard_export("/tmp/nonexistent.zip", ["/nonexistent/path"]) + assert result is False + + +class TestConsolidateArchiveFolders: + """consolidate_archive_folders — merges directories with common slug.""" + + def test_consolidates_dirs(self): + from src.core.utils.fileio import consolidate_archive_folders + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + # Create two dirs with same slug "sales" + d1 = root / "sales_2024" + d1.mkdir() + (d1 / "report1.zip").write_text("zip1") + d2 = root / "sales_2025" + d2.mkdir() + (d2 / "report2.zip").write_text("zip2") + # Unrelated dir + d3 = root / "marketing_2024" + d3.mkdir() + (d3 / "campaign.zip").write_text("zip3") + + consolidate_archive_folders(root) + + # sales_2024 and sales_2025 should be merged into 'sales' + sales_dir = root / "sales" + assert sales_dir.is_dir() + assert (sales_dir / "report1.zip").exists() + assert (sales_dir / "report2.zip").exists() + # marketing should remain separate + assert (root / "marketing_2024").is_dir() + + def test_single_dir_no_consolidation(self): + from src.core.utils.fileio import consolidate_archive_folders + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + d = root / "sales_2024" + d.mkdir() + (d / "report.zip").write_text("zip") + consolidate_archive_folders(root) + assert d.exists() + + def test_raises_on_invalid_input(self): + from src.core.utils.fileio import consolidate_archive_folders + with pytest.raises(AssertionError): + consolidate_archive_folders("not-a-path") + + def test_skips_dirs_without_zips(self): + from src.core.utils.fileio import consolidate_archive_folders + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + (root / "no_zips_here").mkdir() + consolidate_archive_folders(root) + assert (root / "no_zips_here").exists() +# #endregion Test.FileIO.Utils diff --git a/backend/tests/core/test_rate_limiter.py b/backend/tests/core/test_rate_limiter.py new file mode 100644 index 00000000..d9d4101a --- /dev/null +++ b/backend/tests/core/test_rate_limiter.py @@ -0,0 +1,92 @@ +# #region Test.RateLimiter [C:3] [TYPE Module] [SEMANTICS test,rate_limit,auth,throttle] +# @BRIEF Tests for core/rate_limiter.py — in-memory rate limiter, ban/window logic. +# @RELATION BINDS_TO -> [RateLimiterModule] + +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from unittest.mock import patch, MagicMock +import pytest + + +class TestRateLimiter: + """RateLimiter — in-memory sliding-window rate limiter.""" + + @pytest.fixture + def limiter(self): + from src.core.rate_limiter import RateLimiter + return RateLimiter() + + def test_is_banned_initial_false(self, limiter): + assert limiter.is_banned("192.168.1.1") is False + + def test_record_attempt_under_limit(self, limiter): + with patch("src.core.rate_limiter.time") as mock_time: + mock_time.monotonic.return_value = 1000.0 + for i in range(5): # 5 attempts, well under MAX_ATTEMPTS=10 + limiter.record_attempt("192.168.1.1") + assert limiter.is_banned("192.168.1.1") is False + + def test_record_attempt_exceeds_limit(self, limiter): + from src.core.rate_limiter import MAX_ATTEMPTS + with patch("src.core.rate_limiter.time") as mock_time: + mock_time.monotonic.return_value = 1000.0 + for i in range(MAX_ATTEMPTS + 1): + limiter.record_attempt("192.168.1.1") + assert limiter.is_banned("192.168.1.1") is True + + def test_ban_expires(self, limiter): + from src.core.rate_limiter import MAX_ATTEMPTS, BAN_DURATION + with patch("src.core.rate_limiter.time") as mock_time: + mock_time.monotonic.return_value = 1000.0 + for i in range(MAX_ATTEMPTS + 1): + limiter.record_attempt("192.168.1.1") + assert limiter.is_banned("192.168.1.1") is True + + # Advance time past ban duration + mock_time.monotonic.return_value = 1000.0 + BAN_DURATION + 1 + assert limiter.is_banned("192.168.1.1") is False + + def test_old_attempts_pruned(self, limiter): + from src.core.rate_limiter import MAX_ATTEMPTS + with patch("src.core.rate_limiter.time") as mock_time: + mock_time.monotonic.return_value = 1000.0 + for i in range(MAX_ATTEMPTS): + limiter.record_attempt("192.168.1.1") + + # Advance time past window but not to ban expiry + mock_time.monotonic.return_value = 1500.0 # 500s later, past 300s window + # Old attempts should be pruned. One new attempt should not trigger ban. + limiter.record_attempt("192.168.1.1") + assert limiter.is_banned("192.168.1.1") is False + + def test_record_success_clears_attempts(self, limiter): + with patch("src.core.rate_limiter.time") as mock_time: + mock_time.monotonic.return_value = 1000.0 + for i in range(5): + limiter.record_attempt("192.168.1.1") + limiter.record_success("192.168.1.1") + assert limiter.is_banned("192.168.1.1") is False + + def test_different_ips_independent(self, limiter): + with patch("src.core.rate_limiter.time") as mock_time: + mock_time.monotonic.return_value = 1000.0 + for i in range(15): # exceeds limit for ip1 + limiter.record_attempt("192.168.1.1") + assert limiter.is_banned("192.168.1.1") is True + # Different IP should not be affected + assert limiter.is_banned("10.0.0.1") is False + + +class TestRateLimiterSingleton: + """rate_limiter singleton is pre-created.""" + + def test_singleton_exists(self): + from src.core.rate_limiter import rate_limiter + assert rate_limiter is not None + assert hasattr(rate_limiter, "is_banned") + assert hasattr(rate_limiter, "record_attempt") + assert hasattr(rate_limiter, "record_success") +# #endregion Test.RateLimiter