harden released pages and archive openspec change
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
22
tests/test_job_query_frontend_safety.py
Normal file
22
tests/test_job_query_frontend_safety.py
Normal 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
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
62
tests/test_rate_limit_identity.py
Normal file
62
tests/test_rate_limit_identity.py
Normal 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"
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user