test(core): add unit tests for 7 core utility modules
- executors: 13 tests, 100% coverage (init/shutdown/run_blocking/run_cpu_blocking) - fileio_utils: 33 tests, 45% coverage (sanitize_filename, get_filename_from_headers, calculate_crc32, create_temp_file, remove_empty_directories, create_dashboard_export, consolidate_archive_folders) - client_registry: 14 tests, 92% coverage (get_client, get_superset_client, get_semaphore, get_auth_lock, shutdown) - cot_logger: 24 tests, 100% coverage (seed/set/get trace_id, push/pop span, structured log with markers, MarkerLogger proxy) - encryption: 12 tests, 100% coverage (key validation, encrypt/decrypt cycle) - rate_limiter: 9 tests, 100% coverage (ban logic, window pruning, per-IP isolation) - auth/logger: 10 tests, 100% coverage (_mask_details, log_security_event)
This commit is contained in:
88
backend/tests/core/test_auth_logger.py
Normal file
88
backend/tests/core/test_auth_logger.py
Normal file
@@ -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
|
||||
254
backend/tests/core/test_client_registry.py
Normal file
254
backend/tests/core/test_client_registry.py
Normal file
@@ -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
|
||||
199
backend/tests/core/test_cot_logger.py
Normal file
199
backend/tests/core/test_cot_logger.py
Normal file
@@ -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
|
||||
112
backend/tests/core/test_encryption.py
Normal file
112
backend/tests/core/test_encryption.py
Normal file
@@ -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
|
||||
187
backend/tests/core/test_executors.py
Normal file
187
backend/tests/core/test_executors.py
Normal file
@@ -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
|
||||
310
backend/tests/core/test_fileio_utils.py
Normal file
310
backend/tests/core/test_fileio_utils.py
Normal file
@@ -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<b")
|
||||
assert ">" 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
|
||||
92
backend/tests/core/test_rate_limiter.py
Normal file
92
backend/tests/core/test_rate_limiter.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user