harden released pages and archive openspec change

This commit is contained in:
egg
2026-02-23 17:48:32 +08:00
parent 6e2ff9813e
commit e5d7700b36
47 changed files with 2126 additions and 141 deletions

View File

@@ -130,6 +130,34 @@ class AppFactoryTests(unittest.TestCase):
else:
os.environ["PORTAL_SPA_ENABLED"] = old
def test_default_env_is_production_when_flask_env_missing(self):
old_flask_env = os.environ.pop("FLASK_ENV", None)
old_secret = os.environ.get("SECRET_KEY")
old_runtime_contract = os.environ.get("RUNTIME_CONTRACT_ENFORCE")
old_realtime_cache = os.environ.get("REALTIME_EQUIPMENT_CACHE_ENABLED")
try:
os.environ["SECRET_KEY"] = "test-production-secret-key"
os.environ["RUNTIME_CONTRACT_ENFORCE"] = "false"
os.environ["REALTIME_EQUIPMENT_CACHE_ENABLED"] = "false"
app = create_app()
self.assertEqual(app.config.get("ENV"), "production")
finally:
if old_flask_env is not None:
os.environ["FLASK_ENV"] = old_flask_env
if old_secret is None:
os.environ.pop("SECRET_KEY", None)
else:
os.environ["SECRET_KEY"] = old_secret
if old_runtime_contract is None:
os.environ.pop("RUNTIME_CONTRACT_ENFORCE", None)
else:
os.environ["RUNTIME_CONTRACT_ENFORCE"] = old_runtime_contract
if old_realtime_cache is None:
os.environ.pop("REALTIME_EQUIPMENT_CACHE_ENABLED", None)
else:
os.environ["REALTIME_EQUIPMENT_CACHE_ENABLED"] = old_realtime_cache
if __name__ == "__main__":
unittest.main()

View File

@@ -297,8 +297,8 @@ class TestHoldDetailLotsRoute(TestHoldRoutesBase):
call_args = mock_get_lots.call_args
self.assertEqual(call_args.kwargs['page_size'], 200)
@patch('mes_dashboard.routes.hold_routes.get_hold_detail_lots')
def test_handles_page_less_than_one(self, mock_get_lots):
@patch('mes_dashboard.routes.hold_routes.get_hold_detail_lots')
def test_handles_page_less_than_one(self, mock_get_lots):
"""Page number less than 1 should be set to 1."""
mock_get_lots.return_value = {
'lots': [],
@@ -308,8 +308,36 @@ class TestHoldDetailLotsRoute(TestHoldRoutesBase):
response = self.client.get('/api/wip/hold-detail/lots?reason=YieldLimit&page=0')
call_args = mock_get_lots.call_args
self.assertEqual(call_args.kwargs['page'], 1)
call_args = mock_get_lots.call_args
self.assertEqual(call_args.kwargs['page'], 1)
@patch('mes_dashboard.routes.hold_routes.get_hold_detail_lots')
def test_handles_invalid_page_type(self, mock_get_lots):
mock_get_lots.return_value = {
'lots': [],
'pagination': {'page': 1, 'perPage': 50, 'total': 0, 'totalPages': 1},
'filters': {'workcenter': None, 'package': None, 'ageRange': None}
}
response = self.client.get('/api/wip/hold-detail/lots?reason=YieldLimit&page=abc')
self.assertEqual(response.status_code, 200)
call_args = mock_get_lots.call_args
self.assertEqual(call_args.kwargs['page'], 1)
@patch('mes_dashboard.routes.hold_routes.get_hold_detail_lots')
def test_handles_invalid_per_page_type(self, mock_get_lots):
mock_get_lots.return_value = {
'lots': [],
'pagination': {'page': 1, 'perPage': 50, 'total': 0, 'totalPages': 1},
'filters': {'workcenter': None, 'package': None, 'ageRange': None}
}
response = self.client.get('/api/wip/hold-detail/lots?reason=YieldLimit&per_page=abc')
self.assertEqual(response.status_code, 200)
call_args = mock_get_lots.call_args
self.assertEqual(call_args.kwargs['page_size'], 50)
@patch('mes_dashboard.routes.hold_routes.get_hold_detail_lots')
def test_returns_error_on_failure(self, mock_get_lots):

View File

@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
"""Frontend safety contract tests for job-query module rendering."""
from __future__ import annotations
from pathlib import Path
def test_job_query_module_avoids_inline_onclick_string_interpolation():
source = (
Path(__file__).resolve().parents[1]
/ "frontend"
/ "src"
/ "job-query"
/ "main.js"
).read_text(encoding="utf-8")
assert "onclick=" not in source
assert 'data-action="toggle-equipment"' in source
assert 'data-action="toggle-job-history"' in source
assert "encodeURIComponent(safeText(value))" in source
assert "decodeURIComponent(value)" in source

View File

@@ -87,8 +87,45 @@ class TestGetResources:
assert 'ORA-01017' not in data['error']
class TestQueryJobs:
"""Tests for /api/job-query/jobs endpoint."""
class TestQueryJobs:
"""Tests for /api/job-query/jobs endpoint."""
@patch('mes_dashboard.routes.job_query_routes.get_jobs_by_resources')
def test_non_json_payload_returns_415(self, mock_query, client):
response = client.post(
'/api/job-query/jobs',
data='plain-text',
content_type='text/plain',
)
assert response.status_code == 415
payload = response.get_json()
assert 'error' in payload
mock_query.assert_not_called()
@patch('mes_dashboard.routes.job_query_routes.get_jobs_by_resources')
def test_malformed_json_returns_400(self, mock_query, client):
response = client.post(
'/api/job-query/jobs',
data='{"resource_ids":',
content_type='application/json',
)
assert response.status_code == 400
payload = response.get_json()
assert 'error' in payload
mock_query.assert_not_called()
@patch('mes_dashboard.routes.job_query_routes.get_jobs_by_resources')
def test_payload_too_large_returns_413(self, mock_query, client):
client.application.config['MAX_JSON_BODY_BYTES'] = 8
response = client.post(
'/api/job-query/jobs',
data='{"resource_ids":["RES001"]}',
content_type='application/json',
)
assert response.status_code == 413
payload = response.get_json()
assert 'error' in payload
mock_query.assert_not_called()
def test_missing_resource_ids(self, client):
"""Should return error without resource_ids."""
@@ -256,8 +293,32 @@ class TestQueryJobTxnHistory:
assert 'error' in data
class TestExportJobs:
"""Tests for /api/job-query/export endpoint."""
class TestExportJobs:
"""Tests for /api/job-query/export endpoint."""
@patch('mes_dashboard.routes.job_query_routes.export_jobs_with_history')
def test_non_json_payload_returns_415(self, mock_export, client):
response = client.post(
'/api/job-query/export',
data='plain-text',
content_type='text/plain',
)
assert response.status_code == 415
payload = response.get_json()
assert 'error' in payload
mock_export.assert_not_called()
@patch('mes_dashboard.routes.job_query_routes.export_jobs_with_history')
def test_malformed_json_returns_400(self, mock_export, client):
response = client.post(
'/api/job-query/export',
data='{"resource_ids":',
content_type='application/json',
)
assert response.status_code == 400
payload = response.get_json()
assert 'error' in payload
mock_export.assert_not_called()
def test_missing_resource_ids(self, client):
"""Should return error without resource_ids."""

View File

@@ -196,6 +196,22 @@ class TestIsApiPublic:
assert page_registry.is_api_public() is False
def test_api_public_defaults_false_when_key_missing(self, mock_registry, temp_data_file):
data = json.loads(temp_data_file.read_text())
data.pop("api_public", None)
temp_data_file.write_text(json.dumps(data))
page_registry._cache = None
assert page_registry.is_api_public() is False
def test_api_public_invalid_value_defaults_false(self, mock_registry, temp_data_file):
data = json.loads(temp_data_file.read_text())
data["api_public"] = "not-a-bool"
temp_data_file.write_text(json.dumps(data))
page_registry._cache = None
assert page_registry.is_api_public() is False
class TestReloadCache:
"""Tests for reload_cache function."""

View File

@@ -53,8 +53,32 @@ class TestQueryToolPage:
assert b'html' in response.data.lower()
class TestResolveEndpoint:
"""Tests for /api/query-tool/resolve endpoint."""
class TestResolveEndpoint:
"""Tests for /api/query-tool/resolve endpoint."""
@patch('mes_dashboard.routes.query_tool_routes.resolve_lots')
def test_non_json_payload_returns_415(self, mock_resolve, client):
response = client.post(
'/api/query-tool/resolve',
data='plain-text',
content_type='text/plain',
)
assert response.status_code == 415
payload = response.get_json()
assert 'error' in payload
mock_resolve.assert_not_called()
@patch('mes_dashboard.routes.query_tool_routes.resolve_lots')
def test_malformed_json_returns_400(self, mock_resolve, client):
response = client.post(
'/api/query-tool/resolve',
data='{"input_type":',
content_type='application/json',
)
assert response.status_code == 400
payload = response.get_json()
assert 'error' in payload
mock_resolve.assert_not_called()
def test_missing_input_type(self, client):
"""Should return error without input_type."""
@@ -238,7 +262,7 @@ class TestResolveEndpoint:
assert mock_cache_set.call_args.kwargs['ttl'] == 60
class TestLotHistoryEndpoint:
class TestLotHistoryEndpoint:
"""Tests for /api/query-tool/lot-history endpoint."""
def test_missing_container_id(self, client):
@@ -270,15 +294,24 @@ class TestLotHistoryEndpoint:
assert 'data' in data
assert data['total'] == 1
@patch('mes_dashboard.routes.query_tool_routes.get_lot_history')
def test_lot_history_service_error(self, mock_query, client):
"""Should return error from service."""
mock_query.return_value = {'error': '查詢失敗'}
@patch('mes_dashboard.routes.query_tool_routes.get_lot_history')
def test_lot_history_service_error(self, mock_query, client):
"""Should return error from service."""
mock_query.return_value = {'error': '查詢失敗'}
response = client.get('/api/query-tool/lot-history?container_id=invalid')
assert response.status_code == 400
data = json.loads(response.data)
assert 'error' in data
response = client.get('/api/query-tool/lot-history?container_id=invalid')
assert response.status_code == 400
data = json.loads(response.data)
assert 'error' in data
@patch('mes_dashboard.routes.query_tool_routes.get_lot_history_batch')
def test_lot_history_batch_over_limit_returns_413(self, mock_batch, client):
client.application.config['QUERY_TOOL_MAX_CONTAINER_IDS'] = 2
response = client.get('/api/query-tool/lot-history?container_ids=A,B,C')
assert response.status_code == 413
payload = response.get_json()
assert 'error' in payload
mock_batch.assert_not_called()
class TestAdjacentLotsEndpoint:
@@ -425,6 +458,17 @@ class TestLotAssociationsEndpoint:
assert response.status_code == 200
mock_query.assert_called_once_with('488103800029578b', full_history=True)
@patch('mes_dashboard.routes.query_tool_routes.get_lot_associations_batch')
def test_lot_associations_batch_over_limit_returns_413(self, mock_batch, client):
client.application.config['QUERY_TOOL_MAX_CONTAINER_IDS'] = 1
response = client.get(
'/api/query-tool/lot-associations?type=materials&container_ids=A,B'
)
assert response.status_code == 413
payload = response.get_json()
assert 'error' in payload
mock_batch.assert_not_called()
class TestQueryToolRateLimit:
"""Rate-limit behavior for high-cost query-tool endpoints."""
@@ -532,8 +576,32 @@ class TestQueryToolRateLimit:
mock_history.assert_not_called()
class TestEquipmentPeriodEndpoint:
"""Tests for /api/query-tool/equipment-period endpoint."""
class TestEquipmentPeriodEndpoint:
"""Tests for /api/query-tool/equipment-period endpoint."""
@patch('mes_dashboard.routes.query_tool_routes.get_equipment_status_hours')
def test_non_json_payload_returns_415(self, mock_query, client):
response = client.post(
'/api/query-tool/equipment-period',
data='plain-text',
content_type='text/plain',
)
assert response.status_code == 415
payload = response.get_json()
assert 'error' in payload
mock_query.assert_not_called()
@patch('mes_dashboard.routes.query_tool_routes.get_equipment_status_hours')
def test_malformed_json_returns_400(self, mock_query, client):
response = client.post(
'/api/query-tool/equipment-period',
data='{"equipment_ids":',
content_type='application/json',
)
assert response.status_code == 400
payload = response.get_json()
assert 'error' in payload
mock_query.assert_not_called()
def test_missing_query_type(self, client):
"""Should return error without query_type."""
@@ -660,8 +728,32 @@ class TestEquipmentPeriodEndpoint:
assert 'data' in data
class TestExportCsvEndpoint:
"""Tests for /api/query-tool/export-csv endpoint."""
class TestExportCsvEndpoint:
"""Tests for /api/query-tool/export-csv endpoint."""
@patch('mes_dashboard.routes.query_tool_routes.get_lot_history')
def test_non_json_payload_returns_415(self, mock_get_history, client):
response = client.post(
'/api/query-tool/export-csv',
data='plain-text',
content_type='text/plain',
)
assert response.status_code == 415
payload = response.get_json()
assert 'error' in payload
mock_get_history.assert_not_called()
@patch('mes_dashboard.routes.query_tool_routes.get_lot_history')
def test_malformed_json_returns_400(self, mock_get_history, client):
response = client.post(
'/api/query-tool/export-csv',
data='{"export_type":',
content_type='application/json',
)
assert response.status_code == 400
payload = response.get_json()
assert 'error' in payload
mock_get_history.assert_not_called()
def test_missing_export_type(self, client):
"""Should return error without export_type."""

View File

@@ -0,0 +1,62 @@
# -*- coding: utf-8 -*-
"""Tests for rate-limit client identity trust boundary behavior."""
from flask import Flask
from mes_dashboard.core.rate_limit import _client_identifier
def _app() -> Flask:
return Flask(__name__)
def test_client_identifier_ignores_xff_when_proxy_trust_disabled(monkeypatch):
monkeypatch.setenv("TRUST_PROXY_HEADERS", "false")
monkeypatch.delenv("TRUSTED_PROXY_IPS", raising=False)
app = _app()
with app.test_request_context(
"/",
headers={"X-Forwarded-For": "1.2.3.4"},
environ_base={"REMOTE_ADDR": "9.9.9.9"},
):
assert _client_identifier() == "9.9.9.9"
def test_client_identifier_uses_xff_for_trusted_proxy_source(monkeypatch):
monkeypatch.setenv("TRUST_PROXY_HEADERS", "true")
monkeypatch.setenv("TRUSTED_PROXY_IPS", "127.0.0.1")
app = _app()
with app.test_request_context(
"/",
headers={"X-Forwarded-For": "1.2.3.4, 5.6.7.8"},
environ_base={"REMOTE_ADDR": "127.0.0.1"},
):
assert _client_identifier() == "1.2.3.4"
def test_client_identifier_rejects_untrusted_proxy_source(monkeypatch):
monkeypatch.setenv("TRUST_PROXY_HEADERS", "true")
monkeypatch.setenv("TRUSTED_PROXY_IPS", "127.0.0.1")
app = _app()
with app.test_request_context(
"/",
headers={"X-Forwarded-For": "1.2.3.4"},
environ_base={"REMOTE_ADDR": "10.10.10.10"},
):
assert _client_identifier() == "10.10.10.10"
def test_client_identifier_requires_allowlist_when_proxy_trust_enabled(monkeypatch):
monkeypatch.setenv("TRUST_PROXY_HEADERS", "true")
monkeypatch.delenv("TRUSTED_PROXY_IPS", raising=False)
app = _app()
with app.test_request_context(
"/",
headers={"X-Forwarded-For": "1.2.3.4"},
environ_base={"REMOTE_ADDR": "127.0.0.1"},
):
assert _client_identifier() == "127.0.0.1"

View File

@@ -83,13 +83,41 @@ class TestRedisClient:
key = rc.get_key('mykey')
assert key == 'test_prefix:mykey'
def test_get_key_without_prefix(self):
"""Test get_key works with empty prefix."""
import mes_dashboard.core.redis_client as rc
with patch.object(rc, 'REDIS_KEY_PREFIX', ''):
key = rc.get_key('mykey')
assert key == ':mykey'
def test_get_key_without_prefix(self):
"""Test get_key works with empty prefix."""
import mes_dashboard.core.redis_client as rc
with patch.object(rc, 'REDIS_KEY_PREFIX', ''):
key = rc.get_key('mykey')
assert key == ':mykey'
def test_redact_connection_url_masks_password(self):
import mes_dashboard.core.redis_client as rc
redacted = rc.redact_connection_url("redis://user:secret@localhost:6379/0")
assert redacted == "redis://user:***@localhost:6379/0"
def test_redact_connection_url_without_credentials(self):
import mes_dashboard.core.redis_client as rc
redacted = rc.redact_connection_url("redis://localhost:6379/0")
assert redacted == "redis://localhost:6379/0"
def test_get_redis_client_logs_redacted_url(self, reset_module):
import mes_dashboard.core.redis_client as rc
with patch.object(rc, 'REDIS_ENABLED', True):
with patch.object(rc, 'REDIS_URL', 'redis://user:secret@localhost:6379/0'):
with patch.object(rc.redis.Redis, 'from_url') as mock_from_url:
with patch.object(rc.logger, 'info') as mock_info:
mock_client = MagicMock()
mock_client.ping.return_value = True
mock_from_url.return_value = mock_client
rc.get_redis_client()
logged_url = mock_info.call_args.args[1]
assert logged_url == 'redis://user:***@localhost:6379/0'
class TestRedisClientSingleton:

View File

@@ -72,3 +72,77 @@ def test_resource_status_masks_internal_error_details(_mock_status):
assert payload["error"]["code"] == "INTERNAL_ERROR"
assert payload["error"]["message"] == "服務暫時無法使用"
assert "sensitive sql context" not in str(payload)
@patch("mes_dashboard.routes.resource_routes.query_resource_detail")
def test_resource_detail_non_json_payload_returns_415(mock_query):
response = _client().post(
"/api/resource/detail",
data="plain-text",
content_type="text/plain",
)
assert response.status_code == 415
payload = response.get_json()
assert payload["success"] is False
assert "error" in payload
mock_query.assert_not_called()
@patch("mes_dashboard.routes.resource_routes.query_resource_detail")
def test_resource_detail_malformed_json_returns_400(mock_query):
response = _client().post(
"/api/resource/detail",
data='{"filters":',
content_type="application/json",
)
assert response.status_code == 400
payload = response.get_json()
assert payload["success"] is False
assert "error" in payload
mock_query.assert_not_called()
@patch("mes_dashboard.routes.resource_routes.query_resource_detail")
def test_resource_detail_rejects_limit_over_configured_max(mock_query):
client = _client()
client.application.config["RESOURCE_DETAIL_MAX_LIMIT"] = 100
response = client.post(
"/api/resource/detail",
json={"limit": 101, "offset": 0, "filters": {}},
)
assert response.status_code == 413
payload = response.get_json()
assert payload["success"] is False
assert "limit" in payload["error"]
mock_query.assert_not_called()
@patch("mes_dashboard.routes.resource_routes.query_resource_detail")
def test_resource_detail_rejects_invalid_limit_type(mock_query):
response = _client().post(
"/api/resource/detail",
json={"limit": "abc", "offset": 0, "filters": {}},
)
assert response.status_code == 400
payload = response.get_json()
assert payload["success"] is False
assert "limit" in payload["error"]
mock_query.assert_not_called()
@patch("mes_dashboard.routes.resource_routes.query_resource_detail")
def test_resource_detail_rejects_negative_offset(mock_query):
response = _client().post(
"/api/resource/detail",
json={"limit": 10, "offset": -1, "filters": {}},
)
assert response.status_code == 400
payload = response.get_json()
assert payload["success"] is False
assert "offset" in payload["error"]
mock_query.assert_not_called()

View File

@@ -159,6 +159,7 @@ def test_security_headers_applied_globally(testing_app_factory):
assert response.status_code == 200
assert "Content-Security-Policy" in response.headers
assert "frame-ancestors 'self'" in response.headers["Content-Security-Policy"]
assert "'unsafe-eval'" not in response.headers["Content-Security-Policy"]
assert response.headers["X-Frame-Options"] == "SAMEORIGIN"
assert response.headers["X-Content-Type-Options"] == "nosniff"
assert "Referrer-Policy" in response.headers
@@ -181,3 +182,32 @@ def test_hsts_header_enabled_in_production(monkeypatch):
assert "Strict-Transport-Security" in response.headers
_shutdown(app)
def test_csp_unsafe_eval_can_be_enabled_via_env(monkeypatch):
monkeypatch.setenv("CSP_ALLOW_UNSAFE_EVAL", "true")
# Build app directly to control env behavior.
monkeypatch.setenv("REALTIME_EQUIPMENT_CACHE_ENABLED", "false")
db._ENGINE = None
db._HEALTH_ENGINE = None
app = create_app("testing")
app.config["TESTING"] = True
response = app.test_client().get("/", follow_redirects=True)
assert response.status_code == 200
assert "'unsafe-eval'" in response.headers["Content-Security-Policy"]
_shutdown(app)
def test_production_trusted_proxy_requires_allowlist(monkeypatch):
monkeypatch.setenv("SECRET_KEY", "test-production-secret-key")
monkeypatch.setenv("REALTIME_EQUIPMENT_CACHE_ENABLED", "false")
monkeypatch.setenv("RUNTIME_CONTRACT_ENFORCE", "false")
monkeypatch.setenv("TRUST_PROXY_HEADERS", "true")
monkeypatch.delenv("TRUSTED_PROXY_IPS", raising=False)
db._ENGINE = None
db._HEALTH_ENGINE = None
with pytest.raises(RuntimeError, match="TRUSTED_PROXY_IPS"):
create_app("production")

View File

@@ -535,6 +535,32 @@ class TestMetaFilterOptionsRoute(TestWipRoutesBase):
self.assertFalse(data['success'])
class TestMetaSearchRoute(TestWipRoutesBase):
"""Test GET /api/wip/meta/search endpoint."""
@patch('mes_dashboard.routes.wip_routes.search_workorders')
def test_invalid_limit_type_falls_back_to_default(self, mock_search):
mock_search.return_value = []
response = self.client.get('/api/wip/meta/search?field=workorder&q=WO&limit=abc')
data = json.loads(response.data)
self.assertEqual(response.status_code, 200)
self.assertTrue(data['success'])
self.assertEqual(mock_search.call_args.kwargs['limit'], 20)
@patch('mes_dashboard.routes.wip_routes.search_workorders')
def test_limit_is_bounded_with_upper_cap(self, mock_search):
mock_search.return_value = []
response = self.client.get('/api/wip/meta/search?field=workorder&q=WO&limit=999')
data = json.loads(response.data)
self.assertEqual(response.status_code, 200)
self.assertTrue(data['success'])
self.assertEqual(mock_search.call_args.kwargs['limit'], 50)
class TestPageRoutes(TestWipRoutesBase):
"""Test page routes for WIP dashboards."""