chore: finalize vite migration hardening and archive openspec changes
This commit is contained in:
9
tests/fixtures/cache_benchmark_fixture.json
vendored
Normal file
9
tests/fixtures/cache_benchmark_fixture.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"rows": 30000,
|
||||
"query_count": 400,
|
||||
"seed": 42,
|
||||
"thresholds": {
|
||||
"max_p95_ratio_indexed_vs_baseline": 1.25,
|
||||
"max_memory_amplification_ratio": 1.8
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import unittest
|
||||
import os
|
||||
|
||||
from mes_dashboard.app import create_app
|
||||
import mes_dashboard.core.database as db
|
||||
@@ -18,9 +19,17 @@ class AppFactoryTests(unittest.TestCase):
|
||||
self.assertEqual(cache.get("app_factory_probe"), {"ok": True})
|
||||
|
||||
def test_create_app_production_config(self):
|
||||
app = create_app("production")
|
||||
self.assertFalse(app.config.get("DEBUG"))
|
||||
self.assertEqual(app.config.get("ENV"), "production")
|
||||
old_secret = os.environ.get("SECRET_KEY")
|
||||
try:
|
||||
os.environ["SECRET_KEY"] = "test-production-secret-key"
|
||||
app = create_app("production")
|
||||
self.assertFalse(app.config.get("DEBUG"))
|
||||
self.assertEqual(app.config.get("ENV"), "production")
|
||||
finally:
|
||||
if old_secret is None:
|
||||
os.environ.pop("SECRET_KEY", None)
|
||||
else:
|
||||
os.environ["SECRET_KEY"] = old_secret
|
||||
|
||||
def test_create_app_independent_instances(self):
|
||||
app1 = create_app()
|
||||
|
||||
@@ -12,12 +12,18 @@ import os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
|
||||
|
||||
import mes_dashboard.core.database as db
|
||||
from mes_dashboard.app import create_app
|
||||
from mes_dashboard.services import page_registry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_page_status(tmp_path):
|
||||
from mes_dashboard.app import create_app
|
||||
from mes_dashboard.services import page_registry
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _ldap_defaults(monkeypatch):
|
||||
monkeypatch.setattr("mes_dashboard.services.auth_service.LDAP_API_BASE", "https://ldap.panjit.example")
|
||||
monkeypatch.setattr("mes_dashboard.services.auth_service.LDAP_CONFIG_ERROR", None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_page_status(tmp_path):
|
||||
"""Create temporary page status file."""
|
||||
data_file = tmp_path / "page_status.json"
|
||||
initial_data = {
|
||||
@@ -69,9 +75,10 @@ class TestLoginRoute:
|
||||
assert response.status_code == 200
|
||||
assert "管理員登入" in response.data.decode("utf-8") or "login" in response.data.decode("utf-8").lower()
|
||||
|
||||
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_ENABLED', False)
|
||||
@patch('mes_dashboard.services.auth_service.requests.post')
|
||||
def test_login_success(self, mock_post, client):
|
||||
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_ENABLED', False)
|
||||
@patch('mes_dashboard.routes.auth_routes.is_admin', return_value=True)
|
||||
@patch('mes_dashboard.services.auth_service.requests.post')
|
||||
def test_login_success(self, mock_post, _mock_is_admin, client):
|
||||
"""Test successful login via LDAP."""
|
||||
# Mock LDAP response
|
||||
mock_response = MagicMock()
|
||||
|
||||
@@ -1,159 +1,212 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Unit tests for auth_service module."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
|
||||
|
||||
from mes_dashboard.services import auth_service
|
||||
|
||||
|
||||
class TestAuthenticate:
|
||||
"""Tests for authenticate function via LDAP."""
|
||||
|
||||
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_ENABLED', False)
|
||||
@patch('mes_dashboard.services.auth_service.requests.post')
|
||||
def test_authenticate_success(self, mock_post):
|
||||
"""Test successful authentication via LDAP."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"success": True,
|
||||
"user": {
|
||||
"username": "92367",
|
||||
"displayName": "Test User",
|
||||
"mail": "test@panjit.com.tw",
|
||||
"department": "Test Dept"
|
||||
}
|
||||
}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
result = auth_service.authenticate("92367", "password123")
|
||||
|
||||
assert result is not None
|
||||
assert result["username"] == "92367"
|
||||
assert result["mail"] == "test@panjit.com.tw"
|
||||
mock_post.assert_called_once()
|
||||
|
||||
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_ENABLED', False)
|
||||
@patch('mes_dashboard.services.auth_service.requests.post')
|
||||
def test_authenticate_invalid_credentials(self, mock_post):
|
||||
"""Test authentication with invalid credentials via LDAP."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"success": False}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
result = auth_service.authenticate("wrong", "wrong")
|
||||
|
||||
assert result is None
|
||||
|
||||
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_ENABLED', False)
|
||||
@patch('mes_dashboard.services.auth_service.requests.post')
|
||||
def test_authenticate_timeout(self, mock_post):
|
||||
"""Test authentication timeout handling."""
|
||||
import requests
|
||||
mock_post.side_effect = requests.Timeout()
|
||||
|
||||
result = auth_service.authenticate("user", "pass")
|
||||
|
||||
assert result is None
|
||||
|
||||
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_ENABLED', False)
|
||||
@patch('mes_dashboard.services.auth_service.requests.post')
|
||||
def test_authenticate_connection_error(self, mock_post):
|
||||
"""Test authentication connection error handling."""
|
||||
import requests
|
||||
mock_post.side_effect = requests.ConnectionError()
|
||||
|
||||
result = auth_service.authenticate("user", "pass")
|
||||
|
||||
assert result is None
|
||||
|
||||
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_ENABLED', False)
|
||||
@patch('mes_dashboard.services.auth_service.requests.post')
|
||||
def test_authenticate_invalid_json(self, mock_post):
|
||||
"""Test authentication with invalid JSON response."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.side_effect = ValueError("Invalid JSON")
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
result = auth_service.authenticate("user", "pass")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestLocalAuthenticate:
|
||||
"""Tests for local authentication."""
|
||||
|
||||
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_ENABLED', True)
|
||||
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_USERNAME', 'testuser')
|
||||
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_PASSWORD', 'testpass')
|
||||
def test_local_auth_success(self):
|
||||
"""Test successful local authentication."""
|
||||
result = auth_service.authenticate("testuser", "testpass")
|
||||
|
||||
assert result is not None
|
||||
assert result["username"] == "testuser"
|
||||
|
||||
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_ENABLED', True)
|
||||
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_USERNAME', 'testuser')
|
||||
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_PASSWORD', 'testpass')
|
||||
def test_local_auth_wrong_password(self):
|
||||
"""Test local authentication with wrong password."""
|
||||
result = auth_service.authenticate("testuser", "wrongpass")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestIsAdmin:
|
||||
"""Tests for is_admin function."""
|
||||
|
||||
def test_is_admin_with_admin_email(self):
|
||||
"""Test admin check with admin email."""
|
||||
# Save original ADMIN_EMAILS
|
||||
original = auth_service.ADMIN_EMAILS
|
||||
|
||||
try:
|
||||
auth_service.ADMIN_EMAILS = ["admin@panjit.com.tw"]
|
||||
user = {"mail": "admin@panjit.com.tw"}
|
||||
assert auth_service.is_admin(user) is True
|
||||
finally:
|
||||
auth_service.ADMIN_EMAILS = original
|
||||
|
||||
def test_is_admin_with_non_admin_email(self):
|
||||
"""Test admin check with non-admin email."""
|
||||
original = auth_service.ADMIN_EMAILS
|
||||
|
||||
try:
|
||||
auth_service.ADMIN_EMAILS = ["admin@panjit.com.tw"]
|
||||
user = {"mail": "user@panjit.com.tw"}
|
||||
assert auth_service.is_admin(user) is False
|
||||
finally:
|
||||
auth_service.ADMIN_EMAILS = original
|
||||
|
||||
def test_is_admin_case_insensitive(self):
|
||||
"""Test admin check is case insensitive."""
|
||||
original = auth_service.ADMIN_EMAILS
|
||||
|
||||
try:
|
||||
auth_service.ADMIN_EMAILS = ["admin@panjit.com.tw"]
|
||||
user = {"mail": "ADMIN@PANJIT.COM.TW"}
|
||||
assert auth_service.is_admin(user) is True
|
||||
finally:
|
||||
auth_service.ADMIN_EMAILS = original
|
||||
|
||||
def test_is_admin_with_missing_mail(self):
|
||||
"""Test admin check with missing mail field."""
|
||||
user = {}
|
||||
assert auth_service.is_admin(user) is False
|
||||
|
||||
def test_is_admin_with_empty_mail(self):
|
||||
"""Test admin check with empty mail field."""
|
||||
user = {"mail": ""}
|
||||
assert auth_service.is_admin(user) is False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Unit tests for auth_service module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
|
||||
|
||||
from mes_dashboard.services import auth_service
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _ldap_defaults(monkeypatch):
|
||||
"""Keep LDAP auth tests deterministic regardless of host env vars."""
|
||||
monkeypatch.setattr(auth_service, "LDAP_API_BASE", "https://ldap.panjit.example")
|
||||
monkeypatch.setattr(auth_service, "LDAP_CONFIG_ERROR", None)
|
||||
|
||||
|
||||
class TestAuthenticate:
|
||||
"""Tests for authenticate function via LDAP."""
|
||||
|
||||
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_ENABLED', False)
|
||||
@patch('mes_dashboard.services.auth_service.requests.post')
|
||||
def test_authenticate_success(self, mock_post):
|
||||
"""Test successful authentication via LDAP."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"success": True,
|
||||
"user": {
|
||||
"username": "92367",
|
||||
"displayName": "Test User",
|
||||
"mail": "test@panjit.com.tw",
|
||||
"department": "Test Dept"
|
||||
}
|
||||
}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
result = auth_service.authenticate("92367", "password123")
|
||||
|
||||
assert result is not None
|
||||
assert result["username"] == "92367"
|
||||
assert result["mail"] == "test@panjit.com.tw"
|
||||
mock_post.assert_called_once()
|
||||
|
||||
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_ENABLED', False)
|
||||
@patch('mes_dashboard.services.auth_service.requests.post')
|
||||
def test_authenticate_invalid_credentials(self, mock_post):
|
||||
"""Test authentication with invalid credentials via LDAP."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"success": False}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
result = auth_service.authenticate("wrong", "wrong")
|
||||
|
||||
assert result is None
|
||||
|
||||
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_ENABLED', False)
|
||||
@patch('mes_dashboard.services.auth_service.requests.post')
|
||||
def test_authenticate_timeout(self, mock_post):
|
||||
"""Test authentication timeout handling."""
|
||||
import requests
|
||||
mock_post.side_effect = requests.Timeout()
|
||||
|
||||
result = auth_service.authenticate("user", "pass")
|
||||
|
||||
assert result is None
|
||||
|
||||
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_ENABLED', False)
|
||||
@patch('mes_dashboard.services.auth_service.requests.post')
|
||||
def test_authenticate_connection_error(self, mock_post):
|
||||
"""Test authentication connection error handling."""
|
||||
import requests
|
||||
mock_post.side_effect = requests.ConnectionError()
|
||||
|
||||
result = auth_service.authenticate("user", "pass")
|
||||
|
||||
assert result is None
|
||||
|
||||
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_ENABLED', False)
|
||||
@patch('mes_dashboard.services.auth_service.requests.post')
|
||||
def test_authenticate_invalid_json(self, mock_post):
|
||||
"""Test authentication with invalid JSON response."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.side_effect = ValueError("Invalid JSON")
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
result = auth_service.authenticate("user", "pass")
|
||||
|
||||
assert result is None
|
||||
|
||||
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_ENABLED', False)
|
||||
@patch('mes_dashboard.services.auth_service.requests.post')
|
||||
def test_authenticate_rejects_invalid_ldap_config_without_outbound_call(self, mock_post):
|
||||
"""Unsafe LDAP config should block auth and skip outbound request."""
|
||||
with patch.object(auth_service, "LDAP_CONFIG_ERROR", "invalid LDAP API URL"):
|
||||
result = auth_service.authenticate("user", "pass")
|
||||
|
||||
assert result is None
|
||||
mock_post.assert_not_called()
|
||||
|
||||
|
||||
class TestLdapConfigValidation:
|
||||
"""Validate LDAP base URL hardening rules."""
|
||||
|
||||
def test_accepts_https_allowlisted_host(self):
|
||||
api_base, error = auth_service._validate_ldap_api_url(
|
||||
"https://ldap.panjit.example",
|
||||
("ldap.panjit.example",),
|
||||
)
|
||||
|
||||
assert error is None
|
||||
assert api_base == "https://ldap.panjit.example"
|
||||
|
||||
def test_rejects_non_https_url(self):
|
||||
api_base, error = auth_service._validate_ldap_api_url(
|
||||
"http://ldap.panjit.example",
|
||||
("ldap.panjit.example",),
|
||||
)
|
||||
|
||||
assert api_base is None
|
||||
assert error is not None
|
||||
assert "HTTPS" in error
|
||||
|
||||
def test_rejects_non_allowlisted_host(self):
|
||||
api_base, error = auth_service._validate_ldap_api_url(
|
||||
"https://evil.example",
|
||||
("ldap.panjit.example",),
|
||||
)
|
||||
|
||||
assert api_base is None
|
||||
assert error is not None
|
||||
assert "allowlisted" in error
|
||||
|
||||
|
||||
class TestLocalAuthenticate:
|
||||
"""Tests for local authentication."""
|
||||
|
||||
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_ENABLED', True)
|
||||
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_USERNAME', 'testuser')
|
||||
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_PASSWORD', 'testpass')
|
||||
def test_local_auth_success(self):
|
||||
"""Test successful local authentication."""
|
||||
result = auth_service.authenticate("testuser", "testpass")
|
||||
|
||||
assert result is not None
|
||||
assert result["username"] == "testuser"
|
||||
|
||||
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_ENABLED', True)
|
||||
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_USERNAME', 'testuser')
|
||||
@patch('mes_dashboard.services.auth_service.LOCAL_AUTH_PASSWORD', 'testpass')
|
||||
def test_local_auth_wrong_password(self):
|
||||
"""Test local authentication with wrong password."""
|
||||
result = auth_service.authenticate("testuser", "wrongpass")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestIsAdmin:
|
||||
"""Tests for is_admin function."""
|
||||
|
||||
def test_is_admin_with_admin_email(self):
|
||||
"""Test admin check with admin email."""
|
||||
original = auth_service.ADMIN_EMAILS
|
||||
|
||||
try:
|
||||
auth_service.ADMIN_EMAILS = ["admin@panjit.com.tw"]
|
||||
user = {"mail": "admin@panjit.com.tw"}
|
||||
assert auth_service.is_admin(user) is True
|
||||
finally:
|
||||
auth_service.ADMIN_EMAILS = original
|
||||
|
||||
def test_is_admin_with_non_admin_email(self):
|
||||
"""Test admin check with non-admin email."""
|
||||
original = auth_service.ADMIN_EMAILS
|
||||
|
||||
try:
|
||||
auth_service.ADMIN_EMAILS = ["admin@panjit.com.tw"]
|
||||
user = {"mail": "user@panjit.com.tw"}
|
||||
assert auth_service.is_admin(user) is False
|
||||
finally:
|
||||
auth_service.ADMIN_EMAILS = original
|
||||
|
||||
def test_is_admin_case_insensitive(self):
|
||||
"""Test admin check is case insensitive."""
|
||||
original = auth_service.ADMIN_EMAILS
|
||||
|
||||
try:
|
||||
auth_service.ADMIN_EMAILS = ["admin@panjit.com.tw"]
|
||||
user = {"mail": "ADMIN@PANJIT.COM.TW"}
|
||||
assert auth_service.is_admin(user) is True
|
||||
finally:
|
||||
auth_service.ADMIN_EMAILS = original
|
||||
|
||||
def test_is_admin_with_missing_mail(self):
|
||||
"""Test admin check with missing mail field."""
|
||||
user = {}
|
||||
assert auth_service.is_admin(user) is False
|
||||
|
||||
def test_is_admin_with_empty_mail(self):
|
||||
"""Test admin check with empty mail field."""
|
||||
user = {"mail": ""}
|
||||
assert auth_service.is_admin(user) is False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
|
||||
@@ -279,7 +279,7 @@ class TestLayeredCacheTelemetry:
|
||||
assert telemetry['reads_total'] >= 2
|
||||
|
||||
|
||||
class TestIsCacheAvailable:
|
||||
class TestIsCacheAvailable:
|
||||
"""Test is_cache_available function."""
|
||||
|
||||
def test_returns_false_when_disabled(self):
|
||||
@@ -306,8 +306,35 @@ class TestIsCacheAvailable:
|
||||
mock_client = MagicMock()
|
||||
mock_client.exists.return_value = 1
|
||||
|
||||
with patch.object(cache, 'REDIS_ENABLED', True):
|
||||
with patch.object(cache, 'get_redis_client', return_value=mock_client):
|
||||
with patch.object(cache, 'get_key', return_value='mes_wip:data'):
|
||||
result = cache.is_cache_available()
|
||||
assert result is True
|
||||
with patch.object(cache, 'REDIS_ENABLED', True):
|
||||
with patch.object(cache, 'get_redis_client', return_value=mock_client):
|
||||
with patch.object(cache, 'get_key', return_value='mes_wip:data'):
|
||||
result = cache.is_cache_available()
|
||||
assert result is True
|
||||
|
||||
|
||||
class TestProcessLevelCache:
|
||||
"""Test bounded process-level cache behavior."""
|
||||
|
||||
def test_lru_eviction_prefers_recent_keys(self):
|
||||
from mes_dashboard.core.cache import ProcessLevelCache
|
||||
|
||||
cache = ProcessLevelCache(ttl_seconds=60, max_size=2)
|
||||
df1 = pd.DataFrame([{"LOTID": "A"}])
|
||||
df2 = pd.DataFrame([{"LOTID": "B"}])
|
||||
df3 = pd.DataFrame([{"LOTID": "C"}])
|
||||
|
||||
cache.set("a", df1)
|
||||
cache.set("b", df2)
|
||||
assert cache.get("a") is not None # refresh recency for "a"
|
||||
cache.set("c", df3) # should evict "b"
|
||||
|
||||
assert cache.get("b") is None
|
||||
assert cache.get("a") is not None
|
||||
assert cache.get("c") is not None
|
||||
|
||||
def test_wip_process_cache_uses_bounded_config(self):
|
||||
import mes_dashboard.core.cache as cache
|
||||
|
||||
assert cache.WIP_PROCESS_CACHE_MAX_SIZE >= 1
|
||||
assert cache._wip_df_cache.max_size == cache.WIP_PROCESS_CACHE_MAX_SIZE
|
||||
|
||||
@@ -152,11 +152,11 @@ class TestLoadFullTable:
|
||||
class TestUpdateRedisCache:
|
||||
"""Test Redis cache update logic."""
|
||||
|
||||
def test_update_redis_cache_success(self):
|
||||
"""Test _update_redis_cache updates cache correctly."""
|
||||
import mes_dashboard.core.cache_updater as cu
|
||||
|
||||
mock_client = MagicMock()
|
||||
def test_update_redis_cache_success(self):
|
||||
"""Test _update_redis_cache updates cache correctly."""
|
||||
import mes_dashboard.core.cache_updater as cu
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_pipeline = MagicMock()
|
||||
mock_client.pipeline.return_value = mock_pipeline
|
||||
|
||||
@@ -167,22 +167,44 @@ class TestUpdateRedisCache:
|
||||
|
||||
with patch.object(cu, 'get_redis_client', return_value=mock_client):
|
||||
with patch.object(cu, 'get_key', side_effect=lambda k: f'mes_wip:{k}'):
|
||||
updater = cu.CacheUpdater()
|
||||
result = updater._update_redis_cache(test_df, '2024-01-15 10:30:00')
|
||||
updater = cu.CacheUpdater()
|
||||
result = updater._update_redis_cache(test_df, '2024-01-15 10:30:00')
|
||||
|
||||
assert result is True
|
||||
mock_pipeline.rename.assert_called_once()
|
||||
mock_pipeline.execute.assert_called_once()
|
||||
|
||||
assert result is True
|
||||
mock_pipeline.execute.assert_called_once()
|
||||
|
||||
def test_update_redis_cache_no_client(self):
|
||||
"""Test _update_redis_cache handles no client."""
|
||||
import mes_dashboard.core.cache_updater as cu
|
||||
def test_update_redis_cache_no_client(self):
|
||||
"""Test _update_redis_cache handles no client."""
|
||||
import mes_dashboard.core.cache_updater as cu
|
||||
|
||||
test_df = pd.DataFrame({'LOTID': ['LOT001']})
|
||||
|
||||
with patch.object(cu, 'get_redis_client', return_value=None):
|
||||
updater = cu.CacheUpdater()
|
||||
result = updater._update_redis_cache(test_df, '2024-01-15')
|
||||
assert result is False
|
||||
with patch.object(cu, 'get_redis_client', return_value=None):
|
||||
updater = cu.CacheUpdater()
|
||||
result = updater._update_redis_cache(test_df, '2024-01-15')
|
||||
assert result is False
|
||||
|
||||
def test_update_redis_cache_cleans_staging_key_on_failure(self):
|
||||
"""Failed publish should clean staged key and keep function safe."""
|
||||
import mes_dashboard.core.cache_updater as cu
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_pipeline = MagicMock()
|
||||
mock_pipeline.execute.side_effect = RuntimeError("pipeline failed")
|
||||
mock_client.pipeline.return_value = mock_pipeline
|
||||
|
||||
test_df = pd.DataFrame({'LOTID': ['LOT001'], 'QTY': [100]})
|
||||
|
||||
with patch.object(cu, 'get_redis_client', return_value=mock_client):
|
||||
with patch.object(cu, 'get_key', side_effect=lambda k: f'mes_wip:{k}'):
|
||||
updater = cu.CacheUpdater()
|
||||
result = updater._update_redis_cache(test_df, '2024-01-15 10:30:00')
|
||||
|
||||
assert result is False
|
||||
mock_client.delete.assert_called_once()
|
||||
staged_key = mock_client.delete.call_args.args[0]
|
||||
assert "staging" in staged_key
|
||||
|
||||
|
||||
class TestCacheUpdateFlow:
|
||||
|
||||
@@ -221,3 +221,45 @@ class TestCircuitBreakerDisabled:
|
||||
|
||||
# Should still allow (disabled)
|
||||
assert cb.allow_request() is True
|
||||
|
||||
|
||||
class TestCircuitBreakerLogging:
|
||||
"""Verify transition logs are emitted without lock-held I/O."""
|
||||
|
||||
def test_transition_emits_open_log_and_preserves_state(self):
|
||||
cb = CircuitBreaker(
|
||||
"test",
|
||||
failure_threshold=2,
|
||||
failure_rate_threshold=0.5,
|
||||
window_size=4,
|
||||
)
|
||||
|
||||
with patch.object(cb, "_emit_transition_log") as mock_emit:
|
||||
for _ in range(4):
|
||||
cb.record_failure()
|
||||
|
||||
assert cb.state == CircuitState.OPEN
|
||||
mock_emit.assert_called_once()
|
||||
level, message = mock_emit.call_args.args
|
||||
assert level is not None
|
||||
assert "OPENED" in message
|
||||
|
||||
def test_transition_logging_executes_outside_lock(self):
|
||||
cb = CircuitBreaker(
|
||||
"test",
|
||||
failure_threshold=2,
|
||||
failure_rate_threshold=0.5,
|
||||
window_size=4,
|
||||
)
|
||||
|
||||
lock_states: list[bool] = []
|
||||
|
||||
def _capture(_level, _message):
|
||||
lock_states.append(cb._lock.locked())
|
||||
|
||||
with patch.object(cb, "_emit_transition_log", side_effect=_capture):
|
||||
for _ in range(4):
|
||||
cb.record_failure()
|
||||
|
||||
assert lock_states
|
||||
assert all(not state for state in lock_states)
|
||||
|
||||
42
tests/test_database_redaction.py
Normal file
42
tests/test_database_redaction.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for DB secret redaction logging filter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from mes_dashboard.core.database import (
|
||||
SecretRedactionFilter,
|
||||
install_log_redaction_filter,
|
||||
redact_connection_secrets,
|
||||
)
|
||||
|
||||
|
||||
def test_redact_connection_secrets_masks_oracle_url_password():
|
||||
raw = "connect failed: oracle+oracledb://user:super-secret@db-host:1521/service"
|
||||
masked = redact_connection_secrets(raw)
|
||||
|
||||
assert "super-secret" not in masked
|
||||
assert "user:***@" in masked
|
||||
|
||||
|
||||
def test_redact_connection_secrets_masks_db_password_env_pattern():
|
||||
raw = "Runtime config error DB_PASSWORD=myPassword123"
|
||||
masked = redact_connection_secrets(raw)
|
||||
|
||||
assert "myPassword123" not in masked
|
||||
assert "DB_PASSWORD=***" in masked
|
||||
|
||||
|
||||
def test_install_log_redaction_filter_attaches_to_logger_handlers():
|
||||
logger = logging.getLogger("mes_dashboard.test_redaction")
|
||||
logger.handlers = []
|
||||
logger.filters = []
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
handler = logging.StreamHandler()
|
||||
logger.addHandler(handler)
|
||||
|
||||
install_log_redaction_filter(logger)
|
||||
|
||||
assert any(isinstance(f, SecretRedactionFilter) for f in handler.filters)
|
||||
@@ -13,6 +13,8 @@ def _client():
|
||||
db._ENGINE = None
|
||||
app = create_app('testing')
|
||||
app.config['TESTING'] = True
|
||||
from mes_dashboard.routes.health_routes import _reset_health_memo_for_tests
|
||||
_reset_health_memo_for_tests()
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@@ -78,3 +80,37 @@ def test_deep_health_exposes_route_cache_telemetry(
|
||||
assert route_cache['degraded'] is False
|
||||
assert payload['resilience']['recovery_recommendation']['action'] == 'none'
|
||||
assert payload['resilience']['thresholds']['pool_saturation_warning'] >= 0.5
|
||||
|
||||
|
||||
def test_health_memo_store_and_expire(monkeypatch):
|
||||
import mes_dashboard.routes.health_routes as hr
|
||||
|
||||
hr._reset_health_memo_for_tests()
|
||||
monkeypatch.setattr(hr, "_health_memo_enabled", lambda: True)
|
||||
|
||||
hr._set_health_memo("health", {"status": "healthy"}, 200)
|
||||
cached = hr._get_health_memo("health")
|
||||
assert cached == ({"status": "healthy"}, 200)
|
||||
|
||||
with hr._HEALTH_MEMO_LOCK:
|
||||
hr._HEALTH_MEMO["health"] = {"ts": 0.0, "payload": {"status": "old"}, "status": 200}
|
||||
|
||||
with patch("mes_dashboard.routes.health_routes.time.time", return_value=100.0):
|
||||
assert hr._get_health_memo("health") is None
|
||||
|
||||
|
||||
@patch('mes_dashboard.routes.health_routes._health_memo_enabled', return_value=True)
|
||||
@patch('mes_dashboard.routes.health_routes.check_database', return_value=('ok', None))
|
||||
@patch('mes_dashboard.routes.health_routes.check_redis', return_value=('ok', None))
|
||||
def test_health_route_uses_internal_memoization(
|
||||
_mock_redis,
|
||||
mock_db,
|
||||
_mock_enabled,
|
||||
):
|
||||
client = _client()
|
||||
response1 = client.get('/health')
|
||||
response2 = client.get('/health')
|
||||
|
||||
assert response1.status_code == 200
|
||||
assert response2.status_code == 200
|
||||
assert mock_db.call_count == 1
|
||||
|
||||
@@ -46,6 +46,7 @@ class TestAPIResponseFormat:
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data["success"] is True
|
||||
assert "single_port_bind" in data["data"]
|
||||
assert "data" in data
|
||||
|
||||
def test_unauthenticated_redirect(self, client):
|
||||
@@ -249,8 +250,17 @@ class TestWorkerControlAPI:
|
||||
os.unlink(temp_flag) # Remove so we can test creation
|
||||
|
||||
with patch('mes_dashboard.routes.admin_routes.RESTART_FLAG_PATH', temp_flag):
|
||||
with patch('mes_dashboard.routes.admin_routes._check_restart_cooldown', return_value=(False, 0)):
|
||||
response = admin_client.post('/admin/api/worker/restart')
|
||||
with patch(
|
||||
'mes_dashboard.routes.admin_routes._get_restart_policy_state',
|
||||
return_value={
|
||||
"state": "allowed",
|
||||
"allowed": True,
|
||||
"cooldown": False,
|
||||
"blocked": False,
|
||||
"cooldown_remaining_seconds": 0,
|
||||
},
|
||||
):
|
||||
response = admin_client.post('/admin/api/worker/restart', json={})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
@@ -264,14 +274,79 @@ class TestWorkerControlAPI:
|
||||
|
||||
def test_worker_restart_cooldown(self, admin_client):
|
||||
"""Worker restart respects cooldown."""
|
||||
with patch('mes_dashboard.routes.admin_routes._check_restart_cooldown', return_value=(True, 45)):
|
||||
response = admin_client.post('/admin/api/worker/restart')
|
||||
with patch(
|
||||
'mes_dashboard.routes.admin_routes._get_restart_policy_state',
|
||||
return_value={
|
||||
"state": "cooldown",
|
||||
"allowed": False,
|
||||
"cooldown": True,
|
||||
"blocked": False,
|
||||
"cooldown_remaining_seconds": 45,
|
||||
},
|
||||
):
|
||||
response = admin_client.post('/admin/api/worker/restart', json={})
|
||||
|
||||
assert response.status_code == 429
|
||||
data = json.loads(response.data)
|
||||
assert data["success"] is False
|
||||
assert "cooldown" in data["error"]["message"].lower()
|
||||
|
||||
def test_worker_restart_blocked_requires_override(self, admin_client):
|
||||
with patch(
|
||||
'mes_dashboard.routes.admin_routes._get_restart_policy_state',
|
||||
return_value={
|
||||
"state": "blocked",
|
||||
"allowed": False,
|
||||
"cooldown": False,
|
||||
"blocked": True,
|
||||
"cooldown_remaining_seconds": 0,
|
||||
},
|
||||
):
|
||||
response = admin_client.post('/admin/api/worker/restart', json={})
|
||||
|
||||
assert response.status_code == 409
|
||||
data = json.loads(response.data)
|
||||
assert data["success"] is False
|
||||
assert "guarded mode" in data["error"]["message"].lower()
|
||||
|
||||
def test_worker_restart_manual_override_allowed_when_blocked(self, admin_client):
|
||||
fd, temp_flag = tempfile.mkstemp()
|
||||
os.close(fd)
|
||||
os.unlink(temp_flag)
|
||||
|
||||
with (
|
||||
patch('mes_dashboard.routes.admin_routes.RESTART_FLAG_PATH', temp_flag),
|
||||
patch(
|
||||
'mes_dashboard.routes.admin_routes._get_restart_policy_state',
|
||||
return_value={
|
||||
"state": "blocked",
|
||||
"allowed": False,
|
||||
"cooldown": False,
|
||||
"blocked": True,
|
||||
"cooldown_remaining_seconds": 0,
|
||||
},
|
||||
),
|
||||
):
|
||||
response = admin_client.post(
|
||||
'/admin/api/worker/restart',
|
||||
json={
|
||||
"manual_override": True,
|
||||
"override_acknowledged": True,
|
||||
"override_reason": "incident mitigation",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data["success"] is True
|
||||
assert data["data"]["decision"]["decision"] == "manual_override"
|
||||
assert "single_port_bind" in data["data"]
|
||||
|
||||
try:
|
||||
os.unlink(temp_flag)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
class TestCircuitBreakerIntegration:
|
||||
"""Test circuit breaker integration with database layer."""
|
||||
@@ -286,6 +361,12 @@ class TestCircuitBreakerIntegration:
|
||||
assert "state" in cb_status
|
||||
assert "enabled" in cb_status
|
||||
|
||||
def test_system_status_reports_single_port_bind(self, admin_client):
|
||||
response = admin_client.get('/admin/api/system-status')
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data["data"]["single_port_bind"]
|
||||
|
||||
|
||||
class TestPerformancePage:
|
||||
"""Test performance monitoring page."""
|
||||
|
||||
42
tests/test_rate_limit_routes.py
Normal file
42
tests/test_rate_limit_routes.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Route-level rate limit behavior tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import mes_dashboard.core.database as db
|
||||
from mes_dashboard.app import create_app
|
||||
|
||||
|
||||
def _client():
|
||||
db._ENGINE = None
|
||||
app = create_app('testing')
|
||||
app.config['TESTING'] = True
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@patch('mes_dashboard.services.resource_service.get_merged_resource_status')
|
||||
@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 5))
|
||||
def test_resource_status_rate_limit_returns_429(_mock_limit, mock_service):
|
||||
client = _client()
|
||||
response = client.get('/api/resource/status')
|
||||
|
||||
assert response.status_code == 429
|
||||
payload = response.get_json()
|
||||
assert payload['error']['code'] == 'TOO_MANY_REQUESTS'
|
||||
assert response.headers.get('Retry-After') == '5'
|
||||
mock_service.assert_not_called()
|
||||
|
||||
|
||||
@patch('mes_dashboard.services.wip_service.get_hold_detail_lots')
|
||||
@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 4))
|
||||
def test_hold_detail_lots_rate_limit_returns_429(_mock_limit, mock_service):
|
||||
client = _client()
|
||||
response = client.get('/api/wip/hold-detail/lots?reason=YieldLimit')
|
||||
|
||||
assert response.status_code == 429
|
||||
payload = response.get_json()
|
||||
assert payload['error']['code'] == 'TOO_MANY_REQUESTS'
|
||||
assert response.headers.get('Retry-After') == '4'
|
||||
mock_service.assert_not_called()
|
||||
@@ -4,9 +4,10 @@
|
||||
Tests aggregation, status classification, and cache query functionality.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
import json
|
||||
import pandas as pd
|
||||
|
||||
|
||||
class TestClassifyStatus:
|
||||
@@ -297,12 +298,17 @@ class TestGetEquipmentStatusById:
|
||||
"""Test get_equipment_status_by_id function."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_modules(self):
|
||||
"""Reset module state before each test."""
|
||||
import mes_dashboard.core.redis_client as rc
|
||||
rc._REDIS_CLIENT = None
|
||||
yield
|
||||
rc._REDIS_CLIENT = None
|
||||
def reset_modules(self):
|
||||
"""Reset module state before each test."""
|
||||
import mes_dashboard.core.redis_client as rc
|
||||
import mes_dashboard.services.realtime_equipment_cache as eq
|
||||
rc._REDIS_CLIENT = None
|
||||
eq._equipment_status_cache.invalidate("equipment_status_all")
|
||||
eq._invalidate_equipment_status_lookup()
|
||||
yield
|
||||
rc._REDIS_CLIENT = None
|
||||
eq._equipment_status_cache.invalidate("equipment_status_all")
|
||||
eq._invalidate_equipment_status_lookup()
|
||||
|
||||
def test_returns_none_when_redis_unavailable(self):
|
||||
"""Test returns None when Redis client unavailable."""
|
||||
@@ -350,12 +356,17 @@ class TestGetEquipmentStatusByIds:
|
||||
"""Test get_equipment_status_by_ids function."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_modules(self):
|
||||
"""Reset module state before each test."""
|
||||
import mes_dashboard.core.redis_client as rc
|
||||
rc._REDIS_CLIENT = None
|
||||
yield
|
||||
rc._REDIS_CLIENT = None
|
||||
def reset_modules(self):
|
||||
"""Reset module state before each test."""
|
||||
import mes_dashboard.core.redis_client as rc
|
||||
import mes_dashboard.services.realtime_equipment_cache as eq
|
||||
rc._REDIS_CLIENT = None
|
||||
eq._equipment_status_cache.invalidate("equipment_status_all")
|
||||
eq._invalidate_equipment_status_lookup()
|
||||
yield
|
||||
rc._REDIS_CLIENT = None
|
||||
eq._equipment_status_cache.invalidate("equipment_status_all")
|
||||
eq._invalidate_equipment_status_lookup()
|
||||
|
||||
def test_returns_empty_for_empty_input(self):
|
||||
"""Test returns empty list for empty input."""
|
||||
@@ -401,12 +412,17 @@ class TestGetAllEquipmentStatus:
|
||||
"""Test get_all_equipment_status function."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_modules(self):
|
||||
"""Reset module state before each test."""
|
||||
import mes_dashboard.core.redis_client as rc
|
||||
rc._REDIS_CLIENT = None
|
||||
yield
|
||||
rc._REDIS_CLIENT = None
|
||||
def reset_modules(self):
|
||||
"""Reset module state before each test."""
|
||||
import mes_dashboard.core.redis_client as rc
|
||||
import mes_dashboard.services.realtime_equipment_cache as eq
|
||||
rc._REDIS_CLIENT = None
|
||||
eq._equipment_status_cache.invalidate("equipment_status_all")
|
||||
eq._invalidate_equipment_status_lookup()
|
||||
yield
|
||||
rc._REDIS_CLIENT = None
|
||||
eq._equipment_status_cache.invalidate("equipment_status_all")
|
||||
eq._invalidate_equipment_status_lookup()
|
||||
|
||||
def test_returns_empty_when_redis_unavailable(self):
|
||||
"""Test returns empty list when Redis unavailable."""
|
||||
@@ -449,7 +465,7 @@ class TestGetAllEquipmentStatus:
|
||||
assert result[1]['RESOURCEID'] == 'R002'
|
||||
|
||||
|
||||
class TestGetEquipmentStatusCacheStatus:
|
||||
class TestGetEquipmentStatusCacheStatus:
|
||||
"""Test get_equipment_status_cache_status function."""
|
||||
|
||||
@pytest.fixture
|
||||
@@ -489,6 +505,44 @@ class TestGetEquipmentStatusCacheStatus:
|
||||
from mes_dashboard.services.realtime_equipment_cache import get_equipment_status_cache_status
|
||||
result = get_equipment_status_cache_status()
|
||||
|
||||
assert result['enabled'] is True
|
||||
assert result['loaded'] is True
|
||||
assert result['count'] == 1000
|
||||
assert result['enabled'] is True
|
||||
assert result['loaded'] is True
|
||||
assert result['count'] == 1000
|
||||
|
||||
|
||||
class TestEquipmentProcessLevelCache:
|
||||
"""Test bounded process-level cache behavior for equipment status."""
|
||||
|
||||
def test_lru_eviction_prefers_recent_keys(self):
|
||||
import mes_dashboard.services.realtime_equipment_cache as eq
|
||||
|
||||
cache = eq._ProcessLevelCache(ttl_seconds=60, max_size=2)
|
||||
cache.set("a", [{"RESOURCEID": "R001"}])
|
||||
cache.set("b", [{"RESOURCEID": "R002"}])
|
||||
assert cache.get("a") is not None # refresh recency
|
||||
cache.set("c", [{"RESOURCEID": "R003"}]) # should evict "b"
|
||||
|
||||
assert cache.get("b") is None
|
||||
assert cache.get("a") is not None
|
||||
assert cache.get("c") is not None
|
||||
|
||||
def test_global_equipment_cache_uses_bounded_config(self):
|
||||
import mes_dashboard.services.realtime_equipment_cache as eq
|
||||
|
||||
assert eq.EQUIPMENT_PROCESS_CACHE_MAX_SIZE >= 1
|
||||
assert eq._equipment_status_cache.max_size == eq.EQUIPMENT_PROCESS_CACHE_MAX_SIZE
|
||||
|
||||
|
||||
class TestSharedQueryFragments:
|
||||
"""Test shared SQL fragment governance for equipment cache."""
|
||||
|
||||
def test_equipment_load_uses_shared_sql_fragment(self):
|
||||
import mes_dashboard.services.realtime_equipment_cache as eq
|
||||
from mes_dashboard.services.sql_fragments import EQUIPMENT_STATUS_SELECT_SQL
|
||||
|
||||
mock_df = pd.DataFrame([{"RESOURCEID": "R001", "EQUIPMENTID": "EQ-01"}])
|
||||
with patch.object(eq, "read_sql_df", return_value=mock_df) as mock_read:
|
||||
eq._load_equipment_status_from_oracle()
|
||||
|
||||
sql = mock_read.call_args[0][0]
|
||||
assert sql.strip() == EQUIPMENT_STATUS_SELECT_SQL.strip()
|
||||
|
||||
@@ -483,7 +483,7 @@ class TestBuildFilterBuilder:
|
||||
# Parameterized query should have bind variables
|
||||
assert len(params) > 0
|
||||
|
||||
def test_includes_asset_status_filter(self):
|
||||
def test_includes_asset_status_filter(self):
|
||||
"""Test includes asset status exclusion filter with parameterization."""
|
||||
import mes_dashboard.services.resource_cache as rc
|
||||
|
||||
@@ -496,6 +496,17 @@ class TestBuildFilterBuilder:
|
||||
# Parameterized query should have bind variables
|
||||
assert len(params) > 0
|
||||
|
||||
def test_resource_load_uses_shared_sql_fragment_template(self):
|
||||
"""Test resource load path uses shared SQL fragment template."""
|
||||
import mes_dashboard.services.resource_cache as rc
|
||||
from mes_dashboard.services.sql_fragments import RESOURCE_TABLE
|
||||
|
||||
with patch.object(rc, "read_sql_df", return_value=pd.DataFrame()) as mock_read:
|
||||
rc._load_from_oracle()
|
||||
|
||||
sql = mock_read.call_args[0][0]
|
||||
assert RESOURCE_TABLE in sql
|
||||
|
||||
|
||||
class TestResourceDerivedIndex:
|
||||
"""Test derived resource index and telemetry behavior."""
|
||||
@@ -512,9 +523,12 @@ class TestResourceDerivedIndex:
|
||||
def test_get_resource_by_id_uses_index_snapshot(self):
|
||||
import mes_dashboard.services.resource_cache as rc
|
||||
|
||||
cache_df = pd.DataFrame([{"RESOURCEID": "R001", "RESOURCENAME": "Machine1"}])
|
||||
rc._resource_df_cache.set(rc.RESOURCE_DF_CACHE_KEY, cache_df)
|
||||
snapshot = {
|
||||
"records": [{"RESOURCEID": "R001", "RESOURCENAME": "Machine1"}],
|
||||
"by_resource_id": {"R001": {"RESOURCEID": "R001", "RESOURCENAME": "Machine1"}},
|
||||
"ready": True,
|
||||
"all_positions": [0],
|
||||
"by_resource_id": {"R001": 0},
|
||||
}
|
||||
with patch.object(rc, "get_resource_index_snapshot", return_value=snapshot):
|
||||
row = rc.get_resource_by_id("R001")
|
||||
@@ -561,8 +575,8 @@ class TestResourceDerivedIndex:
|
||||
"built_at": "2026-02-07T10:00:05",
|
||||
"version_checked_at": 0.0,
|
||||
"count": 1,
|
||||
"records": [{"RESOURCEID": "OLD"}],
|
||||
"by_resource_id": {"OLD": {"RESOURCEID": "OLD"}},
|
||||
"all_positions": [0],
|
||||
"by_resource_id": {"OLD": 0},
|
||||
}
|
||||
|
||||
rebuilt_df = pd.DataFrame([
|
||||
@@ -576,4 +590,48 @@ class TestResourceDerivedIndex:
|
||||
snapshot = rc.get_resource_index_snapshot()
|
||||
assert snapshot["version"] == "v2"
|
||||
assert snapshot["count"] == 1
|
||||
assert snapshot["by_resource_id"]["R002"]["RESOURCENAME"] == "Machine2"
|
||||
assert snapshot["by_resource_id"]["R002"] == 0
|
||||
records = rc.get_all_resources()
|
||||
assert records[0]["RESOURCENAME"] == "Machine2"
|
||||
|
||||
def test_normalized_index_does_not_store_full_records_copy(self):
|
||||
import mes_dashboard.services.resource_cache as rc
|
||||
|
||||
df = pd.DataFrame([
|
||||
{"RESOURCEID": "R001", "WORKCENTERNAME": "WC1", "PJ_ISPRODUCTION": 1},
|
||||
{"RESOURCEID": "R002", "WORKCENTERNAME": "WC2", "PJ_ISPRODUCTION": 0},
|
||||
])
|
||||
|
||||
index = rc._build_resource_index(df, source="redis", version="v1", updated_at="2026-02-08T00:00:00")
|
||||
assert "records" not in index
|
||||
assert index["by_resource_id"]["R001"] == 0
|
||||
assert index["by_resource_id"]["R002"] == 1
|
||||
assert index["memory"]["index_bytes"] > 0
|
||||
assert index["memory"]["records_json_bytes"] == 0
|
||||
|
||||
|
||||
class TestResourceProcessLevelCache:
|
||||
"""Test bounded process-level cache for resource data."""
|
||||
|
||||
def test_lru_eviction_prefers_recent_keys(self):
|
||||
import mes_dashboard.services.resource_cache as rc
|
||||
|
||||
cache = rc._ProcessLevelCache(ttl_seconds=60, max_size=2)
|
||||
df1 = pd.DataFrame([{"RESOURCEID": "R001"}])
|
||||
df2 = pd.DataFrame([{"RESOURCEID": "R002"}])
|
||||
df3 = pd.DataFrame([{"RESOURCEID": "R003"}])
|
||||
|
||||
cache.set("a", df1)
|
||||
cache.set("b", df2)
|
||||
assert cache.get("a") is not None # refresh recency for "a"
|
||||
cache.set("c", df3) # should evict "b"
|
||||
|
||||
assert cache.get("b") is None
|
||||
assert cache.get("a") is not None
|
||||
assert cache.get("c") is not None
|
||||
|
||||
def test_resource_process_cache_uses_bounded_config(self):
|
||||
import mes_dashboard.services.resource_cache as rc
|
||||
|
||||
assert rc.RESOURCE_PROCESS_CACHE_MAX_SIZE >= 1
|
||||
assert rc._resource_df_cache.max_size == rc.RESOURCE_PROCESS_CACHE_MAX_SIZE
|
||||
|
||||
32
tests/test_resource_routes.py
Normal file
32
tests/test_resource_routes.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for resource route helpers and safeguards."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_clean_nan_values_handles_deep_nesting_without_recursion_error():
|
||||
from mes_dashboard.routes.resource_routes import _clean_nan_values
|
||||
|
||||
payload = current = {}
|
||||
for _ in range(2500):
|
||||
nxt = {}
|
||||
current["next"] = nxt
|
||||
current = nxt
|
||||
current["value"] = float("nan")
|
||||
|
||||
cleaned = _clean_nan_values(payload)
|
||||
cursor = cleaned
|
||||
for _ in range(2500):
|
||||
cursor = cursor["next"]
|
||||
assert cursor["value"] is None
|
||||
|
||||
|
||||
def test_clean_nan_values_breaks_cycles_safely():
|
||||
from mes_dashboard.routes.resource_routes import _clean_nan_values
|
||||
|
||||
payload = {"name": "root"}
|
||||
payload["self"] = payload
|
||||
|
||||
cleaned = _clean_nan_values(payload)
|
||||
assert cleaned["name"] == "root"
|
||||
assert cleaned["self"] is None
|
||||
39
tests/test_runtime_contract.py
Normal file
39
tests/test_runtime_contract.py
Normal file
@@ -0,0 +1,39 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for runtime contract loading and drift validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from mes_dashboard.core.runtime_contract import (
|
||||
load_runtime_contract,
|
||||
validate_runtime_contract,
|
||||
)
|
||||
|
||||
|
||||
def test_runtime_contract_resolves_relative_watchdog_paths(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MES_DASHBOARD_ROOT", str(tmp_path))
|
||||
monkeypatch.setenv("WATCHDOG_RUNTIME_DIR", "./tmp")
|
||||
monkeypatch.setenv("WATCHDOG_RESTART_FLAG", "./tmp/restart.flag")
|
||||
monkeypatch.setenv("WATCHDOG_PID_FILE", "./tmp/gunicorn.pid")
|
||||
monkeypatch.setenv("WATCHDOG_STATE_FILE", "./tmp/restart_state.json")
|
||||
|
||||
contract = load_runtime_contract()
|
||||
assert Path(contract["watchdog_runtime_dir"]) == (tmp_path / "tmp").resolve()
|
||||
assert Path(contract["watchdog_restart_flag"]) == (tmp_path / "tmp" / "restart.flag").resolve()
|
||||
assert Path(contract["watchdog_pid_file"]) == (tmp_path / "tmp" / "gunicorn.pid").resolve()
|
||||
|
||||
|
||||
def test_runtime_contract_detects_flag_pid_drift():
|
||||
contract = {
|
||||
"watchdog_runtime_dir": "/opt/runtime",
|
||||
"watchdog_restart_flag": "/tmp/restart.flag",
|
||||
"watchdog_pid_file": "/tmp/gunicorn.pid",
|
||||
"watchdog_state_file": "/tmp/restart_state.json",
|
||||
"gunicorn_bind": "0.0.0.0:8080",
|
||||
"conda_bin": "",
|
||||
"conda_env_name": "mes-dashboard",
|
||||
}
|
||||
errors = validate_runtime_contract(contract, strict=False)
|
||||
assert any("WATCHDOG_RESTART_FLAG" in err for err in errors)
|
||||
assert any("WATCHDOG_PID_FILE" in err for err in errors)
|
||||
178
tests/test_runtime_hardening.py
Normal file
178
tests/test_runtime_hardening.py
Normal file
@@ -0,0 +1,178 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Runtime hardening tests for startup security, CSRF, and shutdown lifecycle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
import mes_dashboard.core.database as db
|
||||
from mes_dashboard.app import create_app
|
||||
from mes_dashboard.routes.health_routes import check_database
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def testing_app_factory(monkeypatch):
|
||||
def _factory(*, csrf_enabled: bool = False):
|
||||
monkeypatch.setenv("REALTIME_EQUIPMENT_CACHE_ENABLED", "false")
|
||||
db._ENGINE = None
|
||||
db._HEALTH_ENGINE = None
|
||||
app = create_app("testing")
|
||||
app.config["TESTING"] = True
|
||||
app.config["CSRF_ENABLED"] = csrf_enabled
|
||||
return app
|
||||
|
||||
return _factory
|
||||
|
||||
|
||||
def _shutdown(app):
|
||||
shutdown = app.extensions.get("runtime_shutdown")
|
||||
if callable(shutdown):
|
||||
shutdown()
|
||||
|
||||
|
||||
def test_production_requires_secret_key(monkeypatch):
|
||||
monkeypatch.delenv("SECRET_KEY", raising=False)
|
||||
monkeypatch.setenv("REALTIME_EQUIPMENT_CACHE_ENABLED", "false")
|
||||
db._ENGINE = None
|
||||
db._HEALTH_ENGINE = None
|
||||
|
||||
with pytest.raises(RuntimeError, match="SECRET_KEY"):
|
||||
create_app("production")
|
||||
|
||||
|
||||
def test_login_post_rejects_missing_csrf(testing_app_factory):
|
||||
app = testing_app_factory(csrf_enabled=True)
|
||||
client = app.test_client()
|
||||
|
||||
response = client.post("/admin/login", data={"username": "user", "password": "pw"})
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "CSRF" in response.get_data(as_text=True)
|
||||
_shutdown(app)
|
||||
|
||||
|
||||
def test_login_success_rotates_session_and_clears_legacy_state(testing_app_factory):
|
||||
app = testing_app_factory(csrf_enabled=True)
|
||||
client = app.test_client()
|
||||
|
||||
client.get("/admin/login")
|
||||
with client.session_transaction() as sess:
|
||||
sess["_csrf_token"] = "seed-token"
|
||||
sess["legacy"] = "keep-me-out"
|
||||
|
||||
with (
|
||||
patch("mes_dashboard.routes.auth_routes.authenticate") as mock_auth,
|
||||
patch("mes_dashboard.routes.auth_routes.is_admin", return_value=True),
|
||||
):
|
||||
mock_auth.return_value = {
|
||||
"username": "admin",
|
||||
"displayName": "Admin",
|
||||
"mail": "admin@example.com",
|
||||
"department": "IT",
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
"/admin/login?next=/",
|
||||
data={
|
||||
"username": "admin",
|
||||
"password": "secret",
|
||||
"csrf_token": "seed-token",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
|
||||
with client.session_transaction() as sess:
|
||||
assert "legacy" not in sess
|
||||
assert "admin" in sess
|
||||
assert sess.get("_csrf_token")
|
||||
assert sess.get("_csrf_token") != "seed-token"
|
||||
|
||||
_shutdown(app)
|
||||
|
||||
|
||||
def test_runtime_shutdown_hook_invokes_all_cleanup_handlers(testing_app_factory):
|
||||
app = testing_app_factory(csrf_enabled=False)
|
||||
|
||||
with (
|
||||
patch("mes_dashboard.app.stop_cache_updater") as mock_cache_stop,
|
||||
patch("mes_dashboard.app.stop_equipment_status_sync_worker") as mock_sync_stop,
|
||||
patch("mes_dashboard.app.close_redis") as mock_close_redis,
|
||||
patch("mes_dashboard.app.dispose_engine") as mock_dispose_engine,
|
||||
):
|
||||
app.extensions["runtime_shutdown"]()
|
||||
|
||||
mock_cache_stop.assert_called_once()
|
||||
mock_sync_stop.assert_called_once()
|
||||
mock_close_redis.assert_called_once()
|
||||
mock_dispose_engine.assert_called_once()
|
||||
|
||||
_shutdown(app)
|
||||
|
||||
|
||||
def test_health_check_uses_dedicated_health_engine():
|
||||
with patch("mes_dashboard.routes.health_routes.get_health_engine") as mock_engine:
|
||||
conn_ctx = mock_engine.return_value.connect.return_value
|
||||
status, error = check_database()
|
||||
|
||||
assert status == "ok"
|
||||
assert error is None
|
||||
mock_engine.assert_called_once()
|
||||
conn_ctx.__enter__.assert_called_once()
|
||||
|
||||
|
||||
@patch("mes_dashboard.routes.health_routes.check_database", return_value=("ok", None))
|
||||
@patch("mes_dashboard.routes.health_routes.check_redis", return_value=("ok", None))
|
||||
@patch("mes_dashboard.routes.health_routes.get_route_cache_status", return_value={"mode": "l1+l2", "degraded": False})
|
||||
@patch("mes_dashboard.routes.health_routes.get_pool_status", return_value={"saturation": 1.0})
|
||||
@patch("mes_dashboard.core.circuit_breaker.get_circuit_breaker_status", return_value={"state": "CLOSED"})
|
||||
def test_health_reports_pool_saturation_degraded_reason(
|
||||
_mock_circuit,
|
||||
_mock_pool_status,
|
||||
_mock_route_cache,
|
||||
_mock_redis,
|
||||
_mock_db,
|
||||
testing_app_factory,
|
||||
):
|
||||
app = testing_app_factory(csrf_enabled=False)
|
||||
response = app.test_client().get("/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.get_json()
|
||||
assert payload["degraded_reason"] == "db_pool_saturated"
|
||||
assert payload["resilience"]["recovery_recommendation"]["action"] == "consider_controlled_worker_restart"
|
||||
|
||||
_shutdown(app)
|
||||
|
||||
|
||||
def test_security_headers_applied_globally(testing_app_factory):
|
||||
app = testing_app_factory(csrf_enabled=False)
|
||||
response = app.test_client().get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Content-Security-Policy" in response.headers
|
||||
assert response.headers["X-Frame-Options"] == "DENY"
|
||||
assert response.headers["X-Content-Type-Options"] == "nosniff"
|
||||
assert "Referrer-Policy" in response.headers
|
||||
|
||||
_shutdown(app)
|
||||
|
||||
|
||||
def test_hsts_header_enabled_in_production(monkeypatch):
|
||||
monkeypatch.setenv("SECRET_KEY", "test-production-secret-key")
|
||||
monkeypatch.setenv("REALTIME_EQUIPMENT_CACHE_ENABLED", "false")
|
||||
monkeypatch.setenv("RUNTIME_CONTRACT_ENFORCE", "false")
|
||||
db._ENGINE = None
|
||||
db._HEALTH_ENGINE = None
|
||||
|
||||
app = create_app("production")
|
||||
app.config["TESTING"] = True
|
||||
response = app.test_client().get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Strict-Transport-Security" in response.headers
|
||||
|
||||
_shutdown(app)
|
||||
38
tests/test_utils.py
Normal file
38
tests/test_utils.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Unit tests for shared utility helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from mes_dashboard.core.utils import parse_bool_query
|
||||
|
||||
|
||||
def test_parse_bool_query_truthy_values():
|
||||
assert parse_bool_query("true") is True
|
||||
assert parse_bool_query("1") is True
|
||||
assert parse_bool_query("YES") is True
|
||||
|
||||
|
||||
def test_parse_bool_query_falsy_values():
|
||||
assert parse_bool_query("false") is False
|
||||
assert parse_bool_query("0") is False
|
||||
assert parse_bool_query("no") is False
|
||||
|
||||
|
||||
def test_parse_bool_query_unknown_uses_default():
|
||||
assert parse_bool_query("", default=True) is True
|
||||
assert parse_bool_query("not-a-bool", default=False) is False
|
||||
|
||||
|
||||
def test_route_modules_do_not_define_duplicate_parse_bool_helpers():
|
||||
routes_dir = Path(__file__).resolve().parents[1] / "src" / "mes_dashboard" / "routes"
|
||||
route_files = sorted(routes_dir.glob("*_routes.py"))
|
||||
duplicates = []
|
||||
|
||||
for route_file in route_files:
|
||||
text = route_file.read_text(encoding="utf-8")
|
||||
if "def _parse_bool" in text:
|
||||
duplicates.append(route_file.name)
|
||||
|
||||
assert duplicates == []
|
||||
@@ -129,7 +129,7 @@ class TestOverviewHoldRoute(TestWipRoutesBase):
|
||||
self.assertFalse(data['success'])
|
||||
|
||||
|
||||
class TestDetailRoute(TestWipRoutesBase):
|
||||
class TestDetailRoute(TestWipRoutesBase):
|
||||
"""Test GET /api/wip/detail/<workcenter> endpoint."""
|
||||
|
||||
@patch('mes_dashboard.routes.wip_routes.get_wip_detail')
|
||||
@@ -217,10 +217,10 @@ class TestDetailRoute(TestWipRoutesBase):
|
||||
self.assertEqual(call_args.kwargs['page_size'], 500)
|
||||
|
||||
@patch('mes_dashboard.routes.wip_routes.get_wip_detail')
|
||||
def test_handles_page_less_than_one(self, mock_get_detail):
|
||||
"""Page number less than 1 should be set to 1."""
|
||||
mock_get_detail.return_value = {
|
||||
'workcenter': '切割',
|
||||
def test_handles_page_less_than_one(self, mock_get_detail):
|
||||
"""Page number less than 1 should be set to 1."""
|
||||
mock_get_detail.return_value = {
|
||||
'workcenter': '切割',
|
||||
'summary': {'total_lots': 0, 'on_equipment_lots': 0,
|
||||
'waiting_lots': 0, 'hold_lots': 0},
|
||||
'specs': [],
|
||||
@@ -232,19 +232,50 @@ class TestDetailRoute(TestWipRoutesBase):
|
||||
|
||||
response = self.client.get('/api/wip/detail/切割?page=0')
|
||||
|
||||
call_args = mock_get_detail.call_args
|
||||
self.assertEqual(call_args.kwargs['page'], 1)
|
||||
|
||||
@patch('mes_dashboard.routes.wip_routes.get_wip_detail')
|
||||
def test_returns_error_on_failure(self, mock_get_detail):
|
||||
"""Should return success=False and 500 on failure."""
|
||||
mock_get_detail.return_value = None
|
||||
|
||||
response = self.client.get('/api/wip/detail/不存在的工站')
|
||||
data = json.loads(response.data)
|
||||
|
||||
self.assertEqual(response.status_code, 500)
|
||||
self.assertFalse(data['success'])
|
||||
call_args = mock_get_detail.call_args
|
||||
self.assertEqual(call_args.kwargs['page'], 1)
|
||||
|
||||
@patch('mes_dashboard.routes.wip_routes.get_wip_detail')
|
||||
def test_handles_page_size_less_than_one(self, mock_get_detail):
|
||||
"""Page size less than 1 should be set to 1."""
|
||||
mock_get_detail.return_value = {
|
||||
'workcenter': '切割',
|
||||
'summary': {'total_lots': 0, 'on_equipment_lots': 0,
|
||||
'waiting_lots': 0, 'hold_lots': 0},
|
||||
'specs': [],
|
||||
'lots': [],
|
||||
'pagination': {'page': 1, 'page_size': 1,
|
||||
'total_count': 0, 'total_pages': 1},
|
||||
'sys_date': None
|
||||
}
|
||||
|
||||
self.client.get('/api/wip/detail/切割?page_size=0')
|
||||
|
||||
call_args = mock_get_detail.call_args
|
||||
self.assertEqual(call_args.kwargs['page_size'], 1)
|
||||
|
||||
@patch('mes_dashboard.routes.wip_routes.get_wip_detail')
|
||||
def test_returns_error_on_failure(self, mock_get_detail):
|
||||
"""Should return success=False and 500 on failure."""
|
||||
mock_get_detail.return_value = None
|
||||
|
||||
response = self.client.get('/api/wip/detail/不存在的工站')
|
||||
data = json.loads(response.data)
|
||||
|
||||
self.assertEqual(response.status_code, 500)
|
||||
self.assertFalse(data['success'])
|
||||
|
||||
@patch('mes_dashboard.routes.wip_routes.get_wip_detail')
|
||||
@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 7))
|
||||
def test_detail_rate_limited_returns_429(self, _mock_limit, mock_get_detail):
|
||||
"""Rate-limited detail requests should return 429."""
|
||||
response = self.client.get('/api/wip/detail/焊接_DB')
|
||||
data = json.loads(response.data)
|
||||
|
||||
self.assertEqual(response.status_code, 429)
|
||||
self.assertFalse(data['success'])
|
||||
self.assertEqual(data['error']['code'], 'TOO_MANY_REQUESTS')
|
||||
mock_get_detail.assert_not_called()
|
||||
|
||||
|
||||
class TestMetaWorkcentersRoute(TestWipRoutesBase):
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
Tests workcenter group lookup and mapping functionality.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import importlib
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
import pandas as pd
|
||||
|
||||
@@ -347,3 +348,17 @@ class TestGetCacheStatus:
|
||||
assert result['last_refresh'] is not None
|
||||
assert result['workcenter_groups_count'] == 1
|
||||
assert result['workcenter_mapping_count'] == 1
|
||||
|
||||
|
||||
class TestFilterCacheConfig:
|
||||
"""Test environment-based filter-cache source configuration."""
|
||||
|
||||
def test_filter_cache_views_are_env_configurable(self, monkeypatch):
|
||||
monkeypatch.setenv("FILTER_CACHE_WIP_VIEW", "CUSTOM.WIP_VIEW")
|
||||
monkeypatch.setenv("FILTER_CACHE_SPEC_WORKCENTER_VIEW", "CUSTOM.SPEC_VIEW")
|
||||
|
||||
import mes_dashboard.services.filter_cache as fc
|
||||
|
||||
reloaded = importlib.reload(fc)
|
||||
assert reloaded.WIP_VIEW == "CUSTOM.WIP_VIEW"
|
||||
assert reloaded.SPEC_WORKCENTER_VIEW == "CUSTOM.SPEC_VIEW"
|
||||
|
||||
62
tests/test_worker_recovery_policy.py
Normal file
62
tests/test_worker_recovery_policy.py
Normal file
@@ -0,0 +1,62 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Unit tests for worker recovery policy guards."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from mes_dashboard.core.worker_recovery_policy import (
|
||||
decide_restart_request,
|
||||
evaluate_worker_recovery_state,
|
||||
)
|
||||
|
||||
|
||||
def test_policy_enters_blocked_state_when_attempts_exceed_threshold(monkeypatch):
|
||||
monkeypatch.setenv("WORKER_RESTART_RETRY_BUDGET", "2")
|
||||
monkeypatch.setenv("WORKER_RESTART_CHURN_THRESHOLD", "2")
|
||||
monkeypatch.setenv("WORKER_RESTART_WINDOW_SECONDS", "120")
|
||||
monkeypatch.setenv("WORKER_RESTART_COOLDOWN", "10")
|
||||
|
||||
now = datetime(2026, 2, 8, 12, 0, tzinfo=timezone.utc)
|
||||
history = [
|
||||
{"requested_at": (now - timedelta(seconds=30)).isoformat()},
|
||||
{"requested_at": (now - timedelta(seconds=60)).isoformat()},
|
||||
]
|
||||
|
||||
state = evaluate_worker_recovery_state(history, now=now)
|
||||
assert state["blocked"] is True
|
||||
assert state["state"] == "blocked"
|
||||
assert state["allowed"] is False
|
||||
|
||||
|
||||
def test_policy_reports_cooldown_when_recent_request_exists(monkeypatch):
|
||||
monkeypatch.setenv("WORKER_RESTART_RETRY_BUDGET", "5")
|
||||
monkeypatch.setenv("WORKER_RESTART_CHURN_THRESHOLD", "5")
|
||||
monkeypatch.setenv("WORKER_RESTART_WINDOW_SECONDS", "300")
|
||||
monkeypatch.setenv("WORKER_RESTART_COOLDOWN", "60")
|
||||
|
||||
now = datetime(2026, 2, 8, 12, 0, tzinfo=timezone.utc)
|
||||
last_requested = (now - timedelta(seconds=20)).isoformat()
|
||||
state = evaluate_worker_recovery_state([], last_requested_at=last_requested, now=now)
|
||||
assert state["cooldown"] is True
|
||||
assert state["state"] == "cooldown"
|
||||
assert state["cooldown_remaining_seconds"] > 0
|
||||
|
||||
|
||||
def test_manual_override_decision_requires_acknowledgement():
|
||||
blocked_state = {
|
||||
"blocked": True,
|
||||
"cooldown": False,
|
||||
}
|
||||
denied = decide_restart_request(blocked_state, source="manual")
|
||||
assert denied["allowed"] is False
|
||||
assert denied["requires_acknowledgement"] is True
|
||||
|
||||
allowed = decide_restart_request(
|
||||
blocked_state,
|
||||
source="manual",
|
||||
manual_override=True,
|
||||
override_acknowledged=True,
|
||||
)
|
||||
assert allowed["allowed"] is True
|
||||
assert allowed["decision"] == "manual_override"
|
||||
Reference in New Issue
Block a user