harden released pages and archive openspec change
This commit is contained in:
19
.env.example
19
.env.example
@@ -31,6 +31,7 @@ DB_CALL_TIMEOUT_MS=55000 # Must stay below worker timeout
|
||||
# Flask Configuration
|
||||
# ============================================================
|
||||
# Environment mode: development | production | testing
|
||||
# If omitted, runtime defaults to production (fail-safe)
|
||||
FLASK_ENV=development
|
||||
|
||||
# Debug mode: 0 for production, 1 for development
|
||||
@@ -43,6 +44,24 @@ SECRET_KEY=your-secret-key-change-in-production
|
||||
# Session timeout in seconds (default: 28800 = 8 hours)
|
||||
SESSION_LIFETIME=28800
|
||||
|
||||
# JSON request payload upper bound in bytes (default: 262144 = 256KB)
|
||||
MAX_JSON_BODY_BYTES=262144
|
||||
|
||||
# Route input-budget guardrails
|
||||
QUERY_TOOL_MAX_CONTAINER_IDS=200
|
||||
RESOURCE_DETAIL_DEFAULT_LIMIT=500
|
||||
RESOURCE_DETAIL_MAX_LIMIT=500
|
||||
|
||||
# Trust boundary for forwarded headers (safe default: false)
|
||||
# Direct-exposure deployment (no reverse proxy): keep this false
|
||||
TRUST_PROXY_HEADERS=false
|
||||
# Required when TRUST_PROXY_HEADERS=true. Supports comma-separated IP/CIDR entries.
|
||||
# Example: TRUSTED_PROXY_IPS=127.0.0.1,10.0.0.0/24
|
||||
TRUSTED_PROXY_IPS=
|
||||
|
||||
# CSP opt-in compatibility flag (default false = safer)
|
||||
CSP_ALLOW_UNSAFE_EVAL=false
|
||||
|
||||
# ============================================================
|
||||
# Authentication Configuration
|
||||
# ============================================================
|
||||
|
||||
61
.github/workflows/released-pages-hardening-gates.yml
vendored
Normal file
61
.github/workflows/released-pages-hardening-gates.yml
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
name: released-pages-hardening-gates
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "src/mes_dashboard/**"
|
||||
- "frontend/src/job-query/**"
|
||||
- "tests/test_query_tool_routes.py"
|
||||
- "tests/test_job_query_routes.py"
|
||||
- "tests/test_resource_routes.py"
|
||||
- "tests/test_rate_limit_identity.py"
|
||||
- "tests/test_page_registry.py"
|
||||
- "tests/test_redis_client.py"
|
||||
- "tests/test_runtime_hardening.py"
|
||||
- "tests/test_hold_routes.py"
|
||||
- "tests/test_wip_routes.py"
|
||||
- "tests/test_job_query_frontend_safety.py"
|
||||
- ".github/workflows/released-pages-hardening-gates.yml"
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- "src/mes_dashboard/**"
|
||||
- "frontend/src/job-query/**"
|
||||
- "tests/test_query_tool_routes.py"
|
||||
- "tests/test_job_query_routes.py"
|
||||
- "tests/test_resource_routes.py"
|
||||
- "tests/test_rate_limit_identity.py"
|
||||
- "tests/test_page_registry.py"
|
||||
- "tests/test_redis_client.py"
|
||||
- "tests/test_runtime_hardening.py"
|
||||
- "tests/test_hold_routes.py"
|
||||
- "tests/test_wip_routes.py"
|
||||
- "tests/test_job_query_frontend_safety.py"
|
||||
- ".github/workflows/released-pages-hardening-gates.yml"
|
||||
|
||||
jobs:
|
||||
released-pages-hardening:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Install test dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e . pytest
|
||||
- name: Run released-pages hardening regression suite
|
||||
run: |
|
||||
python -m pytest -q \
|
||||
tests/test_query_tool_routes.py \
|
||||
tests/test_job_query_routes.py \
|
||||
tests/test_resource_routes.py \
|
||||
tests/test_rate_limit_identity.py \
|
||||
tests/test_page_registry.py \
|
||||
tests/test_redis_client.py \
|
||||
tests/test_runtime_hardening.py \
|
||||
tests/test_hold_routes.py \
|
||||
tests/test_wip_routes.py \
|
||||
tests/test_job_query_frontend_safety.py \
|
||||
-k "not TestJobQueryPage and not TestHoldDetailPageRoute and not TestPageRoutes"
|
||||
13
README.md
13
README.md
@@ -277,6 +277,19 @@ DB_PASSWORD=your_password
|
||||
# Flask 設定
|
||||
FLASK_ENV=production # production | development
|
||||
SECRET_KEY=your-secret-key # 生產環境請更換
|
||||
MAX_JSON_BODY_BYTES=262144 # JSON 請求大小上限(bytes)
|
||||
|
||||
# 輸入預算保護(Released 高成本 API)
|
||||
QUERY_TOOL_MAX_CONTAINER_IDS=200
|
||||
RESOURCE_DETAIL_DEFAULT_LIMIT=500
|
||||
RESOURCE_DETAIL_MAX_LIMIT=500
|
||||
|
||||
# 反向代理信任邊界(無反向代理時務必維持 false)
|
||||
TRUST_PROXY_HEADERS=false
|
||||
TRUSTED_PROXY_IPS=127.0.0.1
|
||||
|
||||
# CSP 相容開關(預設 false;僅在必要時啟用)
|
||||
CSP_ALLOW_UNSAFE_EVAL=false
|
||||
|
||||
# Gunicorn 設定
|
||||
GUNICORN_BIND=0.0.0.0:8080 # 服務監聽位址
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"in_scope_required_assets": {
|
||||
"/wip-overview": ["wip-overview.js"],
|
||||
"/wip-detail": ["wip-detail.js"],
|
||||
"/hold-overview": ["hold-overview.js"],
|
||||
"/hold-detail": ["hold-detail.js"],
|
||||
"/hold-history": ["hold-history.js"],
|
||||
"/reject-history": ["reject-history.js"],
|
||||
"/resource": ["resource-status.js"],
|
||||
"/resource-history": ["resource-history.js"],
|
||||
"/qc-gate": ["qc-gate.js"],
|
||||
"/job-query": ["job-query.js"],
|
||||
"/tmtt-defect": ["tmtt-defect.js"],
|
||||
"/admin/pages": ["admin-pages.js"],
|
||||
"/admin/performance": ["admin-performance.js"],
|
||||
"/tables": ["tables.js"],
|
||||
"/excel-query": ["excel-query.js"],
|
||||
"/query-tool": ["query-tool.js"],
|
||||
"/mid-section-defect": ["mid-section-defect.js"]
|
||||
},
|
||||
"deferred_routes": []
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"records": []
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"id": "style-legacy-wip-overview",
|
||||
"scope": "/wip-overview",
|
||||
"owner": "frontend-mes-reporting",
|
||||
"milestone": "full-modernization-phase2",
|
||||
"reason": "Legacy styles pending full token and scope migration"
|
||||
},
|
||||
{
|
||||
"id": "style-legacy-wip-detail",
|
||||
"scope": "/wip-detail",
|
||||
"owner": "frontend-mes-reporting",
|
||||
"milestone": "full-modernization-phase2",
|
||||
"reason": "Legacy styles pending full token and scope migration"
|
||||
},
|
||||
{
|
||||
"id": "style-legacy-hold-overview",
|
||||
"scope": "/hold-overview",
|
||||
"owner": "frontend-mes-reporting",
|
||||
"milestone": "full-modernization-phase2",
|
||||
"reason": "Legacy styles pending full token and scope migration"
|
||||
},
|
||||
{
|
||||
"id": "style-legacy-hold-detail",
|
||||
"scope": "/hold-detail",
|
||||
"owner": "frontend-mes-reporting",
|
||||
"milestone": "full-modernization-phase2",
|
||||
"reason": "Legacy styles pending full token and scope migration"
|
||||
},
|
||||
{
|
||||
"id": "style-legacy-hold-history",
|
||||
"scope": "/hold-history",
|
||||
"owner": "frontend-mes-reporting",
|
||||
"milestone": "full-modernization-phase2",
|
||||
"reason": "Legacy styles pending full token and scope migration"
|
||||
},
|
||||
{
|
||||
"id": "style-legacy-reject-history",
|
||||
"scope": "/reject-history",
|
||||
"owner": "frontend-mes-reporting",
|
||||
"milestone": "full-modernization-phase2",
|
||||
"reason": "Legacy styles pending full token and scope migration"
|
||||
},
|
||||
{
|
||||
"id": "style-legacy-resource",
|
||||
"scope": "/resource",
|
||||
"owner": "frontend-mes-reporting",
|
||||
"milestone": "full-modernization-phase2",
|
||||
"reason": "Legacy styles pending full token and scope migration"
|
||||
},
|
||||
{
|
||||
"id": "style-legacy-resource-history",
|
||||
"scope": "/resource-history",
|
||||
"owner": "frontend-mes-reporting",
|
||||
"milestone": "full-modernization-phase2",
|
||||
"reason": "Legacy styles pending full token and scope migration"
|
||||
},
|
||||
{
|
||||
"id": "style-legacy-qc-gate",
|
||||
"scope": "/qc-gate",
|
||||
"owner": "frontend-mes-reporting",
|
||||
"milestone": "full-modernization-phase2",
|
||||
"reason": "Legacy styles pending full token and scope migration"
|
||||
},
|
||||
{
|
||||
"id": "style-legacy-job-query",
|
||||
"scope": "/job-query",
|
||||
"owner": "frontend-mes-reporting",
|
||||
"milestone": "full-modernization-phase2",
|
||||
"reason": "Legacy styles pending full token and scope migration"
|
||||
},
|
||||
{
|
||||
"id": "style-legacy-tmtt-defect",
|
||||
"scope": "/tmtt-defect",
|
||||
"owner": "frontend-mes-reporting",
|
||||
"milestone": "full-modernization-phase2",
|
||||
"reason": "Legacy styles pending full token and scope migration"
|
||||
},
|
||||
{
|
||||
"id": "style-legacy-admin-pages",
|
||||
"scope": "/admin/pages",
|
||||
"owner": "frontend-platform-admin",
|
||||
"milestone": "full-modernization-phase2",
|
||||
"reason": "Legacy styles pending full token and scope migration"
|
||||
},
|
||||
{
|
||||
"id": "style-legacy-admin-performance",
|
||||
"scope": "/admin/performance",
|
||||
"owner": "frontend-platform-admin",
|
||||
"milestone": "full-modernization-phase2",
|
||||
"reason": "Legacy styles pending full token and scope migration"
|
||||
},
|
||||
{
|
||||
"id": "style-legacy-tables",
|
||||
"scope": "/tables",
|
||||
"owner": "frontend-mes-reporting",
|
||||
"milestone": "full-modernization-phase2",
|
||||
"reason": "Legacy styles pending full token and scope migration"
|
||||
},
|
||||
{
|
||||
"id": "style-legacy-excel-query",
|
||||
"scope": "/excel-query",
|
||||
"owner": "frontend-mes-reporting",
|
||||
"milestone": "full-modernization-phase2",
|
||||
"reason": "Legacy styles pending full token and scope migration"
|
||||
},
|
||||
{
|
||||
"id": "style-legacy-query-tool",
|
||||
"scope": "/query-tool",
|
||||
"owner": "frontend-mes-reporting",
|
||||
"milestone": "full-modernization-phase2",
|
||||
"reason": "Legacy styles pending full token and scope migration"
|
||||
},
|
||||
{
|
||||
"id": "style-legacy-mid-section-defect",
|
||||
"scope": "/mid-section-defect",
|
||||
"owner": "frontend-mes-reporting",
|
||||
"milestone": "full-modernization-phase2",
|
||||
"reason": "Legacy styles pending full token and scope migration"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"routes": {
|
||||
"/wip-overview": { "known_issues": [] },
|
||||
"/wip-detail": { "known_issues": [] },
|
||||
"/hold-overview": { "known_issues": [] },
|
||||
"/hold-detail": { "known_issues": [] },
|
||||
"/hold-history": { "known_issues": [] },
|
||||
"/reject-history": { "known_issues": [] },
|
||||
"/resource": { "known_issues": [] },
|
||||
"/resource-history": { "known_issues": [] },
|
||||
"/qc-gate": { "known_issues": [] },
|
||||
"/job-query": { "known_issues": [] },
|
||||
"/tmtt-defect": { "known_issues": [] },
|
||||
"/tables": { "known_issues": [] },
|
||||
"/excel-query": { "known_issues": [] },
|
||||
"/query-tool": { "known_issues": [] },
|
||||
"/mid-section-defect": { "known_issues": [] },
|
||||
"/admin/pages": { "known_issues": [] },
|
||||
"/admin/performance": { "known_issues": [] }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"records": []
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"severity_mode": {
|
||||
"current": "block"
|
||||
},
|
||||
"deferred_routes_excluded": []
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
{
|
||||
"routes": [
|
||||
{
|
||||
"route": "/wip-overview",
|
||||
"route_id": "wip-overview",
|
||||
"title": "WIP Overview",
|
||||
"scope": "in-scope",
|
||||
"render_mode": "native",
|
||||
"owner": "frontend-mes-reporting",
|
||||
"visibility_policy": "released_or_admin",
|
||||
"canonical_shell_path": "/portal-shell/wip-overview",
|
||||
"rollback_strategy": "fallback_to_legacy_route",
|
||||
"compatibility_policy": "redirect_to_shell_when_spa_enabled"
|
||||
},
|
||||
{
|
||||
"route": "/wip-detail",
|
||||
"route_id": "wip-detail",
|
||||
"title": "WIP Detail",
|
||||
"scope": "in-scope",
|
||||
"render_mode": "native",
|
||||
"owner": "frontend-mes-reporting",
|
||||
"visibility_policy": "released_or_admin",
|
||||
"canonical_shell_path": "/portal-shell/wip-detail",
|
||||
"rollback_strategy": "fallback_to_legacy_route",
|
||||
"compatibility_policy": "redirect_to_shell_when_spa_enabled"
|
||||
},
|
||||
{
|
||||
"route": "/hold-overview",
|
||||
"route_id": "hold-overview",
|
||||
"title": "Hold Overview",
|
||||
"scope": "in-scope",
|
||||
"render_mode": "native",
|
||||
"owner": "frontend-mes-reporting",
|
||||
"visibility_policy": "released_or_admin",
|
||||
"canonical_shell_path": "/portal-shell/hold-overview",
|
||||
"rollback_strategy": "fallback_to_legacy_route",
|
||||
"compatibility_policy": "redirect_to_shell_when_spa_enabled"
|
||||
},
|
||||
{
|
||||
"route": "/hold-detail",
|
||||
"route_id": "hold-detail",
|
||||
"title": "Hold Detail",
|
||||
"scope": "in-scope",
|
||||
"render_mode": "native",
|
||||
"owner": "frontend-mes-reporting",
|
||||
"visibility_policy": "released_or_admin",
|
||||
"canonical_shell_path": "/portal-shell/hold-detail",
|
||||
"rollback_strategy": "fallback_to_legacy_route",
|
||||
"compatibility_policy": "redirect_to_shell_when_spa_enabled"
|
||||
},
|
||||
{
|
||||
"route": "/hold-history",
|
||||
"route_id": "hold-history",
|
||||
"title": "Hold History",
|
||||
"scope": "in-scope",
|
||||
"render_mode": "native",
|
||||
"owner": "frontend-mes-reporting",
|
||||
"visibility_policy": "released_or_admin",
|
||||
"canonical_shell_path": "/portal-shell/hold-history",
|
||||
"rollback_strategy": "fallback_to_legacy_route",
|
||||
"compatibility_policy": "redirect_to_shell_when_spa_enabled"
|
||||
},
|
||||
{
|
||||
"route": "/reject-history",
|
||||
"route_id": "reject-history",
|
||||
"title": "Reject History",
|
||||
"scope": "in-scope",
|
||||
"render_mode": "native",
|
||||
"owner": "frontend-mes-reporting",
|
||||
"visibility_policy": "released_or_admin",
|
||||
"canonical_shell_path": "/portal-shell/reject-history",
|
||||
"rollback_strategy": "fallback_to_legacy_route",
|
||||
"compatibility_policy": "redirect_to_shell_when_spa_enabled"
|
||||
},
|
||||
{
|
||||
"route": "/resource",
|
||||
"route_id": "resource",
|
||||
"title": "Resource",
|
||||
"scope": "in-scope",
|
||||
"render_mode": "native",
|
||||
"owner": "frontend-mes-reporting",
|
||||
"visibility_policy": "released_or_admin",
|
||||
"canonical_shell_path": "/portal-shell/resource",
|
||||
"rollback_strategy": "fallback_to_legacy_route",
|
||||
"compatibility_policy": "redirect_to_shell_when_spa_enabled"
|
||||
},
|
||||
{
|
||||
"route": "/resource-history",
|
||||
"route_id": "resource-history",
|
||||
"title": "Resource History",
|
||||
"scope": "in-scope",
|
||||
"render_mode": "native",
|
||||
"owner": "frontend-mes-reporting",
|
||||
"visibility_policy": "released_or_admin",
|
||||
"canonical_shell_path": "/portal-shell/resource-history",
|
||||
"rollback_strategy": "fallback_to_legacy_route",
|
||||
"compatibility_policy": "redirect_to_shell_when_spa_enabled"
|
||||
},
|
||||
{
|
||||
"route": "/qc-gate",
|
||||
"route_id": "qc-gate",
|
||||
"title": "QC Gate",
|
||||
"scope": "in-scope",
|
||||
"render_mode": "native",
|
||||
"owner": "frontend-mes-reporting",
|
||||
"visibility_policy": "released_or_admin",
|
||||
"canonical_shell_path": "/portal-shell/qc-gate",
|
||||
"rollback_strategy": "fallback_to_legacy_route",
|
||||
"compatibility_policy": "redirect_to_shell_when_spa_enabled"
|
||||
},
|
||||
{
|
||||
"route": "/job-query",
|
||||
"route_id": "job-query",
|
||||
"title": "Job Query",
|
||||
"scope": "in-scope",
|
||||
"render_mode": "native",
|
||||
"owner": "frontend-mes-reporting",
|
||||
"visibility_policy": "released_or_admin",
|
||||
"canonical_shell_path": "/portal-shell/job-query",
|
||||
"rollback_strategy": "fallback_to_legacy_route",
|
||||
"compatibility_policy": "redirect_to_shell_when_spa_enabled"
|
||||
},
|
||||
{
|
||||
"route": "/tmtt-defect",
|
||||
"route_id": "tmtt-defect",
|
||||
"title": "TMTT Defect",
|
||||
"scope": "in-scope",
|
||||
"render_mode": "native",
|
||||
"owner": "frontend-mes-reporting",
|
||||
"visibility_policy": "released_or_admin",
|
||||
"canonical_shell_path": "/portal-shell/tmtt-defect",
|
||||
"rollback_strategy": "fallback_to_legacy_route",
|
||||
"compatibility_policy": "redirect_to_shell_when_spa_enabled"
|
||||
},
|
||||
{
|
||||
"route": "/admin/pages",
|
||||
"route_id": "admin-pages",
|
||||
"title": "Admin Pages",
|
||||
"scope": "in-scope",
|
||||
"render_mode": "external",
|
||||
"owner": "frontend-platform-admin",
|
||||
"visibility_policy": "admin_only",
|
||||
"canonical_shell_path": "/portal-shell/admin/pages",
|
||||
"rollback_strategy": "external_route_reversion",
|
||||
"compatibility_policy": "external_target_redirect"
|
||||
},
|
||||
{
|
||||
"route": "/admin/performance",
|
||||
"route_id": "admin-performance",
|
||||
"title": "Admin Performance",
|
||||
"scope": "in-scope",
|
||||
"render_mode": "native",
|
||||
"owner": "frontend-platform-admin",
|
||||
"visibility_policy": "admin_only",
|
||||
"canonical_shell_path": "/portal-shell/admin/performance",
|
||||
"rollback_strategy": "fallback_to_legacy_route",
|
||||
"compatibility_policy": "redirect_to_shell_when_spa_enabled"
|
||||
},
|
||||
{
|
||||
"route": "/tables",
|
||||
"route_id": "tables",
|
||||
"title": "Tables",
|
||||
"scope": "in-scope",
|
||||
"render_mode": "native",
|
||||
"owner": "frontend-mes-reporting",
|
||||
"visibility_policy": "released_or_admin",
|
||||
"canonical_shell_path": "/portal-shell/tables",
|
||||
"rollback_strategy": "fallback_to_legacy_route",
|
||||
"compatibility_policy": "redirect_to_shell_when_spa_enabled"
|
||||
},
|
||||
{
|
||||
"route": "/excel-query",
|
||||
"route_id": "excel-query",
|
||||
"title": "Excel Query",
|
||||
"scope": "in-scope",
|
||||
"render_mode": "native",
|
||||
"owner": "frontend-mes-reporting",
|
||||
"visibility_policy": "released_or_admin",
|
||||
"canonical_shell_path": "/portal-shell/excel-query",
|
||||
"rollback_strategy": "fallback_to_legacy_route",
|
||||
"compatibility_policy": "redirect_to_shell_when_spa_enabled"
|
||||
},
|
||||
{
|
||||
"route": "/query-tool",
|
||||
"route_id": "query-tool",
|
||||
"title": "Query Tool",
|
||||
"scope": "in-scope",
|
||||
"render_mode": "native",
|
||||
"owner": "frontend-mes-reporting",
|
||||
"visibility_policy": "released_or_admin",
|
||||
"canonical_shell_path": "/portal-shell/query-tool",
|
||||
"rollback_strategy": "fallback_to_legacy_route",
|
||||
"compatibility_policy": "redirect_to_shell_when_spa_enabled"
|
||||
},
|
||||
{
|
||||
"route": "/mid-section-defect",
|
||||
"route_id": "mid-section-defect",
|
||||
"title": "Mid Section Defect",
|
||||
"scope": "in-scope",
|
||||
"render_mode": "native",
|
||||
"owner": "frontend-mes-reporting",
|
||||
"visibility_policy": "released_or_admin",
|
||||
"canonical_shell_path": "/portal-shell/mid-section-defect",
|
||||
"rollback_strategy": "fallback_to_legacy_route",
|
||||
"compatibility_policy": "redirect_to_shell_when_spa_enabled"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"in_scope": [
|
||||
{ "route": "/wip-overview", "category": "report" },
|
||||
{ "route": "/wip-detail", "category": "report" },
|
||||
{ "route": "/hold-overview", "category": "report" },
|
||||
{ "route": "/hold-detail", "category": "report" },
|
||||
{ "route": "/hold-history", "category": "report" },
|
||||
{ "route": "/reject-history", "category": "report" },
|
||||
{ "route": "/resource", "category": "report" },
|
||||
{ "route": "/resource-history", "category": "report" },
|
||||
{ "route": "/qc-gate", "category": "report" },
|
||||
{ "route": "/job-query", "category": "report" },
|
||||
{ "route": "/tmtt-defect", "category": "report" },
|
||||
{ "route": "/tables", "category": "report" },
|
||||
{ "route": "/excel-query", "category": "report" },
|
||||
{ "route": "/query-tool", "category": "report" },
|
||||
{ "route": "/mid-section-defect", "category": "report" },
|
||||
{ "route": "/admin/pages", "category": "admin" },
|
||||
{ "route": "/admin/performance", "category": "admin" }
|
||||
],
|
||||
"deferred": []
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"routes": {},
|
||||
"notes": "Baseline placeholder inventory"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"source": "current frontend API consumption contracts",
|
||||
"apis": {
|
||||
"/api/wip/overview/summary": {
|
||||
"required_keys": [
|
||||
"dataUpdateDate",
|
||||
"runLots",
|
||||
"queueLots",
|
||||
"holdLots"
|
||||
],
|
||||
"notes": "summary header and cards depend on these fields"
|
||||
},
|
||||
"/api/wip/overview/matrix": {
|
||||
"required_keys": [
|
||||
"workcenters",
|
||||
"packages",
|
||||
"matrix",
|
||||
"workcenter_totals"
|
||||
],
|
||||
"notes": "matrix table rendering contract"
|
||||
},
|
||||
"/api/wip/hold-detail/summary": {
|
||||
"required_keys": [
|
||||
"workcenterCount",
|
||||
"packageCount",
|
||||
"lotCount"
|
||||
],
|
||||
"notes": "hold detail summary cards contract"
|
||||
},
|
||||
"/api/resource/history/summary": {
|
||||
"required_keys": [
|
||||
"kpi",
|
||||
"trend",
|
||||
"heatmap",
|
||||
"workcenter_comparison"
|
||||
],
|
||||
"notes": "resource history chart summary contract"
|
||||
},
|
||||
"/api/resource/history/detail": {
|
||||
"required_keys": [
|
||||
"data"
|
||||
],
|
||||
"notes": "detail table contract (plus truncated/max_records metadata when present)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"source": "data/page_status.json",
|
||||
"errors": []
|
||||
}
|
||||
201
docs/migration/portal-no-iframe/baseline_drawer_visibility.json
Normal file
201
docs/migration/portal-no-iframe/baseline_drawer_visibility.json
Normal file
@@ -0,0 +1,201 @@
|
||||
{
|
||||
"source": "data/page_status.json",
|
||||
"admin": [
|
||||
{
|
||||
"id": "reports",
|
||||
"name": "即時報表",
|
||||
"order": 1,
|
||||
"admin_only": false,
|
||||
"pages": [
|
||||
{
|
||||
"route": "/wip-overview",
|
||||
"name": "WIP 即時概況",
|
||||
"status": "released",
|
||||
"order": 1
|
||||
},
|
||||
{
|
||||
"route": "/hold-overview",
|
||||
"name": "Hold 即時概況",
|
||||
"status": "released",
|
||||
"order": 2
|
||||
},
|
||||
{
|
||||
"route": "/resource",
|
||||
"name": "設備即時概況",
|
||||
"status": "released",
|
||||
"order": 4
|
||||
},
|
||||
{
|
||||
"route": "/qc-gate",
|
||||
"name": "QC-GATE 狀態",
|
||||
"status": "released",
|
||||
"order": 6
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "drawer-2",
|
||||
"name": "歷史報表",
|
||||
"order": 2,
|
||||
"admin_only": false,
|
||||
"pages": [
|
||||
{
|
||||
"route": "/hold-history",
|
||||
"name": "Hold 歷史績效",
|
||||
"status": "released",
|
||||
"order": 3
|
||||
},
|
||||
{
|
||||
"route": "/reject-history",
|
||||
"name": "報廢歷史查詢",
|
||||
"status": "dev",
|
||||
"order": 4
|
||||
},
|
||||
{
|
||||
"route": "/resource-history",
|
||||
"name": "設備歷史績效",
|
||||
"status": "released",
|
||||
"order": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "drawer",
|
||||
"name": "查詢工具",
|
||||
"order": 3,
|
||||
"admin_only": false,
|
||||
"pages": [
|
||||
{
|
||||
"route": "/job-query",
|
||||
"name": "設備維修查詢",
|
||||
"status": "released",
|
||||
"order": 1
|
||||
},
|
||||
{
|
||||
"route": "/query-tool",
|
||||
"name": "批次追蹤工具",
|
||||
"status": "released",
|
||||
"order": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dev-tools",
|
||||
"name": "開發工具",
|
||||
"order": 4,
|
||||
"admin_only": true,
|
||||
"pages": [
|
||||
{
|
||||
"route": "/tables",
|
||||
"name": "表格總覽",
|
||||
"status": "dev",
|
||||
"order": 1
|
||||
},
|
||||
{
|
||||
"route": "/admin/pages",
|
||||
"name": "頁面管理",
|
||||
"status": "released",
|
||||
"order": 1
|
||||
},
|
||||
{
|
||||
"route": "/excel-query",
|
||||
"name": "Excel 批次查詢",
|
||||
"status": "dev",
|
||||
"order": 2
|
||||
},
|
||||
{
|
||||
"route": "/admin/performance",
|
||||
"name": "效能監控",
|
||||
"status": "dev",
|
||||
"order": 2
|
||||
},
|
||||
{
|
||||
"route": "/tmtt-defect",
|
||||
"name": "TMTT印字腳型不良分析",
|
||||
"status": "dev",
|
||||
"order": 5
|
||||
},
|
||||
{
|
||||
"route": "/mid-section-defect",
|
||||
"name": "中段製程不良追溯",
|
||||
"status": "dev",
|
||||
"order": 6
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"non_admin": [
|
||||
{
|
||||
"id": "reports",
|
||||
"name": "即時報表",
|
||||
"order": 1,
|
||||
"admin_only": false,
|
||||
"pages": [
|
||||
{
|
||||
"route": "/wip-overview",
|
||||
"name": "WIP 即時概況",
|
||||
"status": "released",
|
||||
"order": 1
|
||||
},
|
||||
{
|
||||
"route": "/hold-overview",
|
||||
"name": "Hold 即時概況",
|
||||
"status": "released",
|
||||
"order": 2
|
||||
},
|
||||
{
|
||||
"route": "/resource",
|
||||
"name": "設備即時概況",
|
||||
"status": "released",
|
||||
"order": 4
|
||||
},
|
||||
{
|
||||
"route": "/qc-gate",
|
||||
"name": "QC-GATE 狀態",
|
||||
"status": "released",
|
||||
"order": 6
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "drawer-2",
|
||||
"name": "歷史報表",
|
||||
"order": 2,
|
||||
"admin_only": false,
|
||||
"pages": [
|
||||
{
|
||||
"route": "/hold-history",
|
||||
"name": "Hold 歷史績效",
|
||||
"status": "released",
|
||||
"order": 3
|
||||
},
|
||||
{
|
||||
"route": "/resource-history",
|
||||
"name": "設備歷史績效",
|
||||
"status": "released",
|
||||
"order": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "drawer",
|
||||
"name": "查詢工具",
|
||||
"order": 3,
|
||||
"admin_only": false,
|
||||
"pages": [
|
||||
{
|
||||
"route": "/job-query",
|
||||
"name": "設備維修查詢",
|
||||
"status": "released",
|
||||
"order": 1
|
||||
},
|
||||
{
|
||||
"route": "/query-tool",
|
||||
"name": "批次追蹤工具",
|
||||
"status": "released",
|
||||
"order": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"source": "frontend route parsing and current parity matrix",
|
||||
"routes": {
|
||||
"/wip-overview": {
|
||||
"query_keys": [
|
||||
"workorder",
|
||||
"lotid",
|
||||
"package",
|
||||
"type",
|
||||
"status"
|
||||
],
|
||||
"notes": "filters + status URL state must remain compatible"
|
||||
},
|
||||
"/wip-detail": {
|
||||
"query_keys": [
|
||||
"workcenter",
|
||||
"workorder",
|
||||
"lotid",
|
||||
"package",
|
||||
"type",
|
||||
"status"
|
||||
],
|
||||
"notes": "workcenter deep-link and back-link query continuity"
|
||||
},
|
||||
"/hold-detail": {
|
||||
"query_keys": [
|
||||
"reason"
|
||||
],
|
||||
"notes": "reason required for normal access flow"
|
||||
},
|
||||
"/resource-history": {
|
||||
"query_keys": [
|
||||
"start_date",
|
||||
"end_date",
|
||||
"granularity",
|
||||
"workcenter_groups",
|
||||
"families",
|
||||
"resource_ids",
|
||||
"is_production",
|
||||
"is_key",
|
||||
"is_monitor"
|
||||
],
|
||||
"notes": "query/export params must remain compatible"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,27 @@ window.__FIELD_CONTRACTS__['job_query:txn_table'] = getPageContract('job_query',
|
||||
const jobTableFields = getPageContract('job_query', 'jobs_table');
|
||||
const txnTableFields = getPageContract('job_query', 'txn_table');
|
||||
|
||||
function toDataToken(value) {
|
||||
return encodeURIComponent(safeText(value));
|
||||
}
|
||||
|
||||
function fromDataToken(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch (_error) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function renderJobCell(job, apiKey) {
|
||||
if (apiKey === 'JOBSTATUS') {
|
||||
const value = safeText(job[apiKey]);
|
||||
return `<span class="status-badge ${value}">${value}</span>`;
|
||||
const classToken = safeText(value).replace(/[^A-Za-z0-9_-]/g, '_');
|
||||
const escaped = escapeHtml(value);
|
||||
return `<span class="status-badge ${classToken}">${escaped}</span>`;
|
||||
}
|
||||
if (apiKey === 'CREATEDATE' || apiKey === 'COMPLETEDATE') {
|
||||
return formatDate(job[apiKey]);
|
||||
@@ -25,7 +42,9 @@ function renderJobCell(job, apiKey) {
|
||||
function renderTxnCell(txn, apiKey) {
|
||||
if (apiKey === 'FROMJOBSTATUS' || apiKey === 'JOBSTATUS') {
|
||||
const value = safeText(txn[apiKey], '-');
|
||||
return `<span class="status-badge ${escapeHtml(value)}">${escapeHtml(value)}</span>`;
|
||||
const classToken = safeText(value).replace(/[^A-Za-z0-9_-]/g, '_');
|
||||
const escaped = escapeHtml(value);
|
||||
return `<span class="status-badge ${classToken}">${escaped}</span>`;
|
||||
}
|
||||
if (apiKey === 'TXNDATE') {
|
||||
return formatDate(txn[apiKey]);
|
||||
@@ -48,6 +67,16 @@ function renderTxnCell(txn, apiKey) {
|
||||
loadEquipments();
|
||||
setLast90Days();
|
||||
|
||||
const equipmentList = document.getElementById('equipmentList');
|
||||
if (equipmentList) {
|
||||
equipmentList.addEventListener('click', handleEquipmentListClick);
|
||||
}
|
||||
|
||||
const resultSection = document.getElementById('resultSection');
|
||||
if (resultSection) {
|
||||
resultSection.addEventListener('click', handleResultSectionClick);
|
||||
}
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
const dropdown = document.getElementById('equipmentDropdown');
|
||||
@@ -94,20 +123,22 @@ function renderTxnCell(txn, apiKey) {
|
||||
const allSelected = selectedInGroup === groupIds.length;
|
||||
const someSelected = selectedInGroup > 0 && !allSelected;
|
||||
const escapedName = escapeHtml(workcenterName);
|
||||
html += `<div class="workcenter-group-header" onclick="toggleWorkcenterGroup('${escapedName}')">
|
||||
<input type="checkbox" ${allSelected ? 'checked' : ''} ${someSelected ? 'class="indeterminate"' : ''} onclick="event.stopPropagation(); toggleWorkcenterGroup('${escapedName}')">
|
||||
const workcenterToken = toDataToken(workcenterName);
|
||||
html += `<div class="workcenter-group-header" data-action="toggle-workcenter-group" data-workcenter="${workcenterToken}">
|
||||
<input type="checkbox" ${allSelected ? 'checked' : ''} ${someSelected ? 'class="indeterminate"' : ''} data-action="toggle-workcenter-group" data-workcenter="${workcenterToken}">
|
||||
<span class="workcenter-group-name">${escapedName}</span>
|
||||
<span class="workcenter-group-count">${selectedInGroup}/${groupIds.length}</span>
|
||||
</div>`;
|
||||
groupEquipments.forEach((eq) => {
|
||||
const isSelected = selectedEquipments.has(eq.RESOURCEID);
|
||||
const resourceId = escapeHtml(safeText(eq.RESOURCEID));
|
||||
const resourceId = safeText(eq.RESOURCEID);
|
||||
const resourceIdToken = toDataToken(resourceId);
|
||||
const resourceName = escapeHtml(safeText(eq.RESOURCENAME));
|
||||
const familyName = escapeHtml(safeText(eq.RESOURCEFAMILYNAME));
|
||||
|
||||
html += `
|
||||
<div class="equipment-item ${isSelected ? 'selected' : ''}" onclick="toggleEquipment('${resourceId}')">
|
||||
<input type="checkbox" ${isSelected ? 'checked' : ''} onclick="event.stopPropagation(); toggleEquipment('${resourceId}')">
|
||||
<div class="equipment-item ${isSelected ? 'selected' : ''}" data-action="toggle-equipment" data-resource-id="${resourceIdToken}">
|
||||
<input type="checkbox" ${isSelected ? 'checked' : ''} data-action="toggle-equipment" data-resource-id="${resourceIdToken}">
|
||||
<div class="equipment-info">
|
||||
<div class="equipment-name">${resourceName}</div>
|
||||
<div class="equipment-workcenter">${familyName}</div>
|
||||
@@ -120,6 +151,30 @@ function renderTxnCell(txn, apiKey) {
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function handleEquipmentListClick(event) {
|
||||
const trigger = event.target.closest('[data-action]');
|
||||
if (!trigger) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (trigger.dataset.action === 'toggle-workcenter-group') {
|
||||
const workcenterName = fromDataToken(trigger.dataset.workcenter);
|
||||
if (!workcenterName) {
|
||||
return;
|
||||
}
|
||||
toggleWorkcenterGroup(workcenterName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (trigger.dataset.action === 'toggle-equipment') {
|
||||
const resourceId = fromDataToken(trigger.dataset.resourceId);
|
||||
if (!resourceId) {
|
||||
return;
|
||||
}
|
||||
toggleEquipment(resourceId);
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle equipment dropdown
|
||||
function toggleEquipmentDropdown() {
|
||||
const dropdown = document.getElementById('equipmentDropdown');
|
||||
@@ -299,8 +354,8 @@ function renderTxnCell(txn, apiKey) {
|
||||
<div class="result-header">
|
||||
<div class="result-info">共 ${jobsData.length} 筆工單</div>
|
||||
<div class="result-actions">
|
||||
<button class="btn btn-secondary btn-sm" onclick="expandAll()">全部展開</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="collapseAll()">全部收合</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" data-action="expand-all">全部展開</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" data-action="collapse-all">全部收合</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
@@ -316,13 +371,14 @@ function renderTxnCell(txn, apiKey) {
|
||||
|
||||
jobsData.forEach((job, idx) => {
|
||||
const isExpanded = expandedJobs.has(job.JOBID);
|
||||
const jobIdToken = toDataToken(job.JOBID);
|
||||
const jobCells = jobTableFields
|
||||
.map((field) => `<td>${renderJobCell(job, field.api_key)}</td>`)
|
||||
.join('');
|
||||
html += `
|
||||
<tr class="job-row ${isExpanded ? 'expanded' : ''}" id="job-row-${idx}">
|
||||
<td>
|
||||
<button class="expand-btn" onclick="toggleJobHistory('${escapeHtml(safeText(job.JOBID))}', ${idx})">
|
||||
<button type="button" class="expand-btn" data-action="toggle-job-history" data-job-id="${jobIdToken}" data-row-index="${idx}">
|
||||
<span class="arrow-icon ${isExpanded ? 'rotated' : ''}">▶</span>
|
||||
</button>
|
||||
</td>
|
||||
@@ -355,6 +411,31 @@ function renderTxnCell(txn, apiKey) {
|
||||
void loadHistoriesBatched(pendingLoads);
|
||||
}
|
||||
|
||||
function handleResultSectionClick(event) {
|
||||
const trigger = event.target.closest('[data-action]');
|
||||
if (!trigger) {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = trigger.dataset.action;
|
||||
if (action === 'expand-all') {
|
||||
expandAll();
|
||||
return;
|
||||
}
|
||||
if (action === 'collapse-all') {
|
||||
collapseAll();
|
||||
return;
|
||||
}
|
||||
if (action === 'toggle-job-history') {
|
||||
const idx = Number.parseInt(trigger.dataset.rowIndex || '', 10);
|
||||
const jobId = fromDataToken(trigger.dataset.jobId);
|
||||
if (!Number.isInteger(idx) || !jobId) {
|
||||
return;
|
||||
}
|
||||
void toggleJobHistory(jobId, idx);
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle job history
|
||||
async function toggleJobHistory(jobId, idx) {
|
||||
const txnRow = document.getElementById(`txn-row-${idx}`);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-23
|
||||
@@ -0,0 +1,90 @@
|
||||
## Context
|
||||
|
||||
Released 頁面已直接使用於生產,且現行部署為單層對外服務(無反向代理)。現況存在多個交叉風險:
|
||||
- JSON 解析錯誤可能透過全域 exception handler 回落為 500。
|
||||
- 部分高成本查詢端點缺乏批量輸入與查詢筆數上限。
|
||||
- rate-limit client key 可能受 `X-Forwarded-For` spoofing 影響。
|
||||
- 設定載入在缺漏時存在偏寬鬆預設(含 API 可見性、環境模式)。
|
||||
- 記錄連線 URL 時可能暴露敏感資訊。
|
||||
- 前端仍有 inline handler 字串插值路徑。
|
||||
|
||||
本變更屬跨模組 hardening(routes/core/config/frontend/tests),且要求在不破壞 Released 正常流程下補齊安全與穩定性基線。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 將 Released 高風險端點的輸入錯誤語義固定為可預期 4xx。
|
||||
- 對 batch / detail 查詢導入可設定的硬上限與拒絕策略。
|
||||
- 在無 proxy 預設下建立正確的 rate-limit 信任邊界。
|
||||
- 將生產安全設定調整為 fail-safe 預設並加入啟動檢查。
|
||||
- 移除已知前端 inline 插值風險點並補強測試,確保無回歸。
|
||||
|
||||
**Non-Goals:**
|
||||
- 不重寫 Released 頁面的商業邏輯或資料模型。
|
||||
- 不改動 Oracle schema 或新增外部服務。
|
||||
- 不一次性移除全站所有 legacy inline script(以風險最高路徑優先)。
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision 1: 建立一致的 JSON 輸入驗證邊界,將解析失敗明確轉為 4xx
|
||||
- 選擇:在 Released 相關 JSON routes 採一致的 request parsing helper(含 content-type 與 malformed JSON 驗證),回傳 400/415;僅真正未預期例外才走 500。
|
||||
- 理由:修正「客戶端錯誤被誤判為服務端錯誤」並提升可觀測性。
|
||||
- 替代方案:維持各 route 自行 `get_json()` + 全域 handler。
|
||||
- 未採用原因:行為不一致且易再次回歸 500。
|
||||
|
||||
### Decision 2: 以設定驅動的輸入預算(input budget)治理高成本端點
|
||||
- 選擇:新增集中化上限設定(例如 `QUERY_TOOL_MAX_CONTAINER_IDS`、`RESOURCE_DETAIL_MAX_LIMIT`、`MAX_JSON_BODY_BYTES`),route 先驗證再呼叫 service。
|
||||
- 理由:避免 hardcode 分散、便於環境調優與壓測。
|
||||
- 替代方案:在 service 層被動截斷或依 DB timeout 自然保護。
|
||||
- 未採用原因:無法在入口即時拒絕,仍浪費應用資源。
|
||||
|
||||
### Decision 3: 以「預設不信任 proxy headers」實作 rate-limit identity
|
||||
- 選擇:新增 `TRUST_PROXY_HEADERS=false` 預設;只有顯式開啟且來源符合 trusted proxy 條件時才使用 `X-Forwarded-For`。
|
||||
- 理由:符合當前無反向代理部署現況,避免 IP spoofing 使限流失效。
|
||||
- 替代方案:永遠信任 XFF。
|
||||
- 未採用原因:對外直連部署下可被任意偽造。
|
||||
|
||||
### Decision 4: 生產安全設定 fail-safe 與敏感資訊遮罩
|
||||
- 選擇:`api_public` 缺值或配置錯誤時預設 false;`SECRET_KEY` 等關鍵安全變數缺失時拒絕啟動或進入明確受限模式;所有 URL 型密鑰資訊在 log 遮罩。
|
||||
- 理由:把「配置失誤」從安全事件轉為可診斷的啟動錯誤。
|
||||
- 替代方案:保留寬鬆 fallback(例如預設公開 API)。
|
||||
- 未採用原因:與生產最小暴露原則衝突。
|
||||
|
||||
### Decision 5: 前端高風險 inline handler 先行替換為安全事件綁定
|
||||
- 選擇:針對 Released 且已觀察到風險的 job-query 動作欄位,改為 data attribute + addEventListener;避免 raw 字串 `onclick` 插值。
|
||||
- 理由:以最小變更降低 XSS/斷裂風險且不影響 UX。
|
||||
- 替代方案:一次性重構所有頁面事件綁定。
|
||||
- 未採用原因:變更面過大,不利快速風險收斂。
|
||||
|
||||
### Decision 6: 以「負向測試 + 既有契約測試」雙軌防回歸
|
||||
- 選擇:新增 hardening 專屬負向測試(invalid JSON、超量輸入、限流來源、secret redaction)並保留既有 released route 正向契約測試,兩者皆納入 CI gate。
|
||||
- 理由:確保防護生效且既有功能不被破壞。
|
||||
- 替代方案:僅補單元測試或手動驗證。
|
||||
- 未採用原因:無法長期防止行為漂移。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Risk] 新增 4xx 驗證可能影響少量既有錯誤處理流程 → Mitigation: 僅對 JSON-only endpoint 啟用,並以契約測試固定成功路徑。
|
||||
- [Risk] 輸入上限過低可能影響查詢體驗 → Mitigation: 上限參數化並透過壓測/實際流量校準。
|
||||
- [Risk] fail-safe 設定可能在配置不完整時阻擋啟動 → Mitigation: 發布前檢查清單與啟動時清楚錯誤訊息。
|
||||
- [Risk] 前端事件綁定改動造成局部互動差異 → Mitigation: 補 UI 行為測試與手動 smoke 驗證。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 新增設定鍵與預設值(輸入上限、proxy trust、安全啟動檢查),保留清楚註解與環境文件。
|
||||
2. 先改 route 層 JSON 驗證與批量上限檢查,再補 service 防線(雙層保護)。
|
||||
3. 更新 rate-limit client identity resolver,預設走 `remote_addr`。
|
||||
4. 加入 Redis URL log redaction 與 page registry fail-safe 預設。
|
||||
5. 調整 job-query 前端事件綁定,移除高風險 inline 插值。
|
||||
6. 補齊測試:負向 API、限流信任邊界、設定 fail-safe、log redaction、既有 released route 契約。
|
||||
7. CI 全綠後部署;若出現非預期拒絕,僅允許透過設定值調整上限,不回退安全語義。
|
||||
|
||||
Rollback Strategy:
|
||||
- 若發生突發相容性問題,優先調整上限配置與 trusted proxy 配置;
|
||||
- 嚴禁回退到「信任任意 XFF」或「invalid JSON 回 500」行為;
|
||||
- 必要時暫時放寬單一端點上限,但保留防護機制本身。
|
||||
|
||||
## Open Questions
|
||||
|
||||
- `container_ids` 與 `resource detail limit` 的正式預設值是否以現網 P95 請求分佈定版(例如 200 / 500)?
|
||||
- trusted proxy 是否需要 CIDR allowlist(而非單純 bool)以支援未來拓樸演進?
|
||||
@@ -0,0 +1,37 @@
|
||||
## Why
|
||||
|
||||
Released 頁面目前直接套用到生產環境,且部署型態為無反向代理的單層對外服務;現況在 API 輸入驗證、流量防護、設定安全預設、與錯誤處理上仍有可導致 500、資源耗盡或安全邊界被繞過的風險。需要以一次性治理方式補齊基線,並建立可重複執行的無回歸驗證,避免修正後再次退化。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 統一 Released 頁面相關高成本 API 的輸入驗證與錯誤語義:非 JSON 或格式錯誤請求回覆 4xx,不再落入 500。
|
||||
- 為 query-tool 與 resource 等批次/明細查詢加入明確上限(批量 ID、limit、payload size)與拒絕策略,降低 DoS 與慢查風險。
|
||||
- 強化 rate-limit 客戶端識別信任邊界:在無 trusted proxy 情境下不可直接信任 `X-Forwarded-For`。
|
||||
- 對生產安全設定採 fail-safe 預設:`api_public`、`FLASK_ENV`、`SECRET_KEY`、Redis URL log masking 等。
|
||||
- 收斂前端可注入風險(如 inline handler 字串插值)與 CSP 風險設定,降低 XSS 面。
|
||||
- 建立 Released 頁面專屬無回歸驗證矩陣(正向、負向、壓力邊界、契約),納入 CI gate。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `released-pages-production-hardening`: 定義 Released 頁面在生產環境的輸入驗證、資源保護、信任邊界、安全預設與回歸防線要求。
|
||||
|
||||
### Modified Capabilities
|
||||
- None.
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected code:
|
||||
- `src/mes_dashboard/routes/job_query_routes.py`
|
||||
- `src/mes_dashboard/routes/query_tool_routes.py`
|
||||
- `src/mes_dashboard/routes/resource_routes.py`
|
||||
- `src/mes_dashboard/routes/hold_routes.py`
|
||||
- `src/mes_dashboard/routes/wip_routes.py`
|
||||
- `src/mes_dashboard/core/rate_limit.py`
|
||||
- `src/mes_dashboard/core/redis_client.py`
|
||||
- `src/mes_dashboard/config/settings.py`
|
||||
- `src/mes_dashboard/app.py`
|
||||
- `frontend/src/job-query/main.js`
|
||||
- `data/page_status.json`
|
||||
- APIs/routes: Released route 對應 API(包含 `/api/query-tool/*`, `/api/job-query/*`, `/api/resource/*` 等)會新增/明確化 4xx 與 429 邊界行為。
|
||||
- Tests/quality gates: 新增與擴充 Released 頁面 API 的負向驗證、限流、上限邊界與模板整合回歸測試;CI 需納入通過條件。
|
||||
@@ -0,0 +1,81 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Released Query APIs SHALL Return 4xx for Invalid JSON Inputs
|
||||
Released 頁面對應的 JSON API 在收到非 JSON、Malformed JSON、或型別不符 payload 時,MUST 回覆可預期的 4xx 錯誤,且 MUST NOT 因 JSON 解析失敗回落為 500。
|
||||
|
||||
#### Scenario: Non-JSON request to JSON-only endpoint
|
||||
- **WHEN** client 以 `Content-Type: text/plain` 或缺少 JSON body 呼叫 JSON-only endpoint(例如 `/api/query-tool/*`、`/api/job-query/*`、`/api/resource/detail`)
|
||||
- **THEN** endpoint MUST 回覆 400 或 415,並提供一致的錯誤訊息
|
||||
- **THEN** service layer MUST NOT 執行高成本查詢
|
||||
|
||||
#### Scenario: Malformed JSON payload
|
||||
- **WHEN** client 送出無法解析的 JSON 內容
|
||||
- **THEN** endpoint MUST 回覆 400
|
||||
- **THEN** response MUST 指出 payload 格式錯誤,而非 generic 500
|
||||
|
||||
### Requirement: High-Cost Batch Inputs SHALL Enforce Hard Upper Bounds
|
||||
Released 頁面高成本查詢端點 MUST 對批量輸入與查詢筆數上限施加硬限制,避免單次請求造成過量資料讀取或計算。
|
||||
|
||||
#### Scenario: Query-tool batch container IDs exceed limit
|
||||
- **WHEN** `container_ids` 數量超過設定上限
|
||||
- **THEN** endpoint MUST 回覆 400 或 413,且 MUST 附帶可操作的上限資訊
|
||||
- **THEN** backend MUST NOT 執行 Oracle/Redis 高成本查詢流程
|
||||
|
||||
#### Scenario: Resource detail limit exceeds limit
|
||||
- **WHEN** `/api/resource/detail` 的 `limit` 超過設定上限
|
||||
- **THEN** endpoint MUST 拒絕請求或安全夾制至上限,並在契約中明確定義行為
|
||||
- **THEN** response 行為 MUST 於測試中固定化,避免版本漂移
|
||||
|
||||
### Requirement: Rate-Limit Client Identity SHALL Respect Trust Boundary
|
||||
Rate limiting 的 client identity 解析 MUST 依部署信任邊界運作,未啟用 trusted proxy 時 MUST NOT 直接信任 `X-Forwarded-For`。
|
||||
|
||||
#### Scenario: Direct internet deployment without reverse proxy
|
||||
- **WHEN** 服務直接對外且未啟用 trusted proxy 模式
|
||||
- **THEN** rate-limit key MUST 使用 `remote_addr`(或等價來源)
|
||||
- **THEN** 來自 request header 的 `X-Forwarded-For` MUST 被忽略
|
||||
|
||||
#### Scenario: Deployment with trusted reverse proxy enabled
|
||||
- **WHEN** 系統明確配置 trusted proxy 名單或模式
|
||||
- **THEN** rate-limit key MAY 使用 `X-Forwarded-For` 的可信 client IP
|
||||
- **THEN** 非可信來源 MUST 回退至 `remote_addr`
|
||||
|
||||
### Requirement: Production Security Defaults SHALL Fail Safe
|
||||
生產設定在缺漏或格式錯誤時 MUST 採 fail-safe 預設,避免 API 無意外暴露或低安全模式啟動。
|
||||
|
||||
#### Scenario: page status config missing or invalid
|
||||
- **WHEN** `page_status.json` 缺失、破損或缺少 `api_public` 設定
|
||||
- **THEN** runtime MUST 預設為 API 非公開(`api_public=false`)
|
||||
- **THEN** 需要明確配置才可開啟公開 API 行為
|
||||
|
||||
#### Scenario: runtime environment variables incomplete
|
||||
- **WHEN** 生產啟動缺少關鍵安全變數(例如 `SECRET_KEY`)
|
||||
- **THEN** 系統 MUST 以安全方式拒絕啟動或進入受限模式,且輸出可診斷訊息
|
||||
|
||||
### Requirement: Sensitive Configuration Values SHALL Be Redacted in Logs
|
||||
任何含憑證的連線字串(例如 Redis URL)在 log 輸出時 MUST 進行遮罩,避免密碼外洩。
|
||||
|
||||
#### Scenario: Redis URL includes password
|
||||
- **WHEN** 應用程式記錄 Redis 連線設定
|
||||
- **THEN** log 中的 URL MUST 隱藏密碼(例如 `redis://***@host:port/db`)
|
||||
- **THEN** 原始明文密碼 MUST NOT 出現在任何應用層日誌
|
||||
|
||||
### Requirement: Released Frontend Views SHALL Avoid Unsafe Inline Interpolation
|
||||
Released 頁面前端 MUST 避免將不受信資料直接插入 inline JavaScript 或 HTML 屬性字串,降低 XSS 與 handler 斷裂風險。
|
||||
|
||||
#### Scenario: Rendering action controls with user-derived values
|
||||
- **WHEN** 前端渲染按鈕或互動控制(例如 job-query 操作欄)且內容含資料列值
|
||||
- **THEN** MUST 透過安全資料綁定(data-* attribute 或事件監聽)實作
|
||||
- **THEN** MUST NOT 依賴 raw string `onclick="...${value}..."` 拼接
|
||||
|
||||
### Requirement: Released Hardening SHALL Be Protected by Regression Gates
|
||||
本次 hardening 的行為 MUST 由自動化測試固定,並納入 CI gate,避免日後回歸。
|
||||
|
||||
#### Scenario: Negative-path regression suite execution
|
||||
- **WHEN** CI 執行 Released 頁面 API 測試
|
||||
- **THEN** MUST 覆蓋 invalid JSON、超量輸入、rate-limit、security default、與 log redaction 斷言
|
||||
- **THEN** 任一關鍵斷言失敗 MUST 阻擋合併
|
||||
|
||||
#### Scenario: Existing released behavior parity
|
||||
- **WHEN** hardening 變更部署後執行既有 Released route 測試
|
||||
- **THEN** 成功路徑與既有回應契約 MUST 維持相容
|
||||
- **THEN** 僅新增已定義的防護錯誤路徑(4xx/429)
|
||||
@@ -0,0 +1,34 @@
|
||||
## 1. Config and Core Safety Baseline
|
||||
|
||||
- [x] 1.1 Add centralized hardening config keys (`TRUST_PROXY_HEADERS`, trusted proxy source config, JSON/body/input limits) with production-safe defaults.
|
||||
- [x] 1.2 Change page registry fallback behavior so `api_public` defaults to false when config is missing/invalid.
|
||||
- [x] 1.3 Implement secret redaction utility for connection-string logging and apply it to Redis URL logs.
|
||||
- [x] 1.4 Enforce startup validation for required production security variables (including `SECRET_KEY`) with actionable diagnostics.
|
||||
- [x] 1.5 Update environment documentation (`.env.example`/README/deploy docs) to match new hardening settings.
|
||||
|
||||
## 2. Released API Input Validation and Budget Guards
|
||||
|
||||
- [x] 2.1 Introduce a shared JSON request parsing/validation helper and adopt it in released JSON-only endpoints (`query-tool`, `job-query`, `resource` related routes).
|
||||
- [x] 2.2 Ensure invalid/malformed/non-JSON payloads return deterministic 400/415 and do not fall through to generic 500 handlers.
|
||||
- [x] 2.3 Add configurable hard caps for query-tool batch inputs (including `container_ids`) and reject overflow requests before service execution.
|
||||
- [x] 2.4 Add configurable `limit` bounds for `/api/resource/detail` and normalize/reject invalid pagination limits consistently.
|
||||
- [x] 2.5 Fix released route numeric query parsing edge cases to avoid `TypeError`/500 regressions.
|
||||
|
||||
## 3. Rate-Limit Trust Boundary Hardening
|
||||
|
||||
- [x] 3.1 Refactor rate-limit client identity resolution to ignore `X-Forwarded-For` by default and use `remote_addr` in direct-exposure deployments.
|
||||
- [x] 3.2 Add trusted-proxy mode behavior so forwarded IP is used only when explicit trust configuration is enabled.
|
||||
- [x] 3.3 Add tests for spoofed header attempts, direct mode behavior, and trusted-proxy behavior.
|
||||
|
||||
## 4. Frontend Injection-Surface Reduction
|
||||
|
||||
- [x] 4.1 Refactor `job-query` action rendering to remove raw inline `onclick` interpolation and use safe event binding/data attributes.
|
||||
- [x] 4.2 Review and tighten applicable CSP/script-safety configuration for released routes without breaking current module/fallback loading.
|
||||
- [x] 4.3 Add frontend/template tests to lock down safe rendering behavior for quoted/special-character data.
|
||||
|
||||
## 5. Regression Gates and Verification
|
||||
|
||||
- [x] 5.1 Add negative-path tests for invalid JSON, oversized batch input, bounded `limit`, and no-service-call-on-reject behavior.
|
||||
- [x] 5.2 Add config hardening tests for `api_public` fail-safe fallback, production env validation, and Redis URL redaction.
|
||||
- [x] 5.3 Run released-route focused pytest suite and update/repair affected contract tests to reflect explicit new 4xx/429 boundaries only.
|
||||
- [x] 5.4 Ensure CI requires the new hardening test set to pass before merge.
|
||||
85
openspec/specs/released-pages-production-hardening/spec.md
Normal file
85
openspec/specs/released-pages-production-hardening/spec.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# released-pages-production-hardening Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change released-pages-production-hardening. Update Purpose after archive.
|
||||
## Requirements
|
||||
### Requirement: Released Query APIs SHALL Return 4xx for Invalid JSON Inputs
|
||||
Released 頁面對應的 JSON API 在收到非 JSON、Malformed JSON、或型別不符 payload 時,MUST 回覆可預期的 4xx 錯誤,且 MUST NOT 因 JSON 解析失敗回落為 500。
|
||||
|
||||
#### Scenario: Non-JSON request to JSON-only endpoint
|
||||
- **WHEN** client 以 `Content-Type: text/plain` 或缺少 JSON body 呼叫 JSON-only endpoint(例如 `/api/query-tool/*`、`/api/job-query/*`、`/api/resource/detail`)
|
||||
- **THEN** endpoint MUST 回覆 400 或 415,並提供一致的錯誤訊息
|
||||
- **THEN** service layer MUST NOT 執行高成本查詢
|
||||
|
||||
#### Scenario: Malformed JSON payload
|
||||
- **WHEN** client 送出無法解析的 JSON 內容
|
||||
- **THEN** endpoint MUST 回覆 400
|
||||
- **THEN** response MUST 指出 payload 格式錯誤,而非 generic 500
|
||||
|
||||
### Requirement: High-Cost Batch Inputs SHALL Enforce Hard Upper Bounds
|
||||
Released 頁面高成本查詢端點 MUST 對批量輸入與查詢筆數上限施加硬限制,避免單次請求造成過量資料讀取或計算。
|
||||
|
||||
#### Scenario: Query-tool batch container IDs exceed limit
|
||||
- **WHEN** `container_ids` 數量超過設定上限
|
||||
- **THEN** endpoint MUST 回覆 400 或 413,且 MUST 附帶可操作的上限資訊
|
||||
- **THEN** backend MUST NOT 執行 Oracle/Redis 高成本查詢流程
|
||||
|
||||
#### Scenario: Resource detail limit exceeds limit
|
||||
- **WHEN** `/api/resource/detail` 的 `limit` 超過設定上限
|
||||
- **THEN** endpoint MUST 拒絕請求或安全夾制至上限,並在契約中明確定義行為
|
||||
- **THEN** response 行為 MUST 於測試中固定化,避免版本漂移
|
||||
|
||||
### Requirement: Rate-Limit Client Identity SHALL Respect Trust Boundary
|
||||
Rate limiting 的 client identity 解析 MUST 依部署信任邊界運作,未啟用 trusted proxy 時 MUST NOT 直接信任 `X-Forwarded-For`。
|
||||
|
||||
#### Scenario: Direct internet deployment without reverse proxy
|
||||
- **WHEN** 服務直接對外且未啟用 trusted proxy 模式
|
||||
- **THEN** rate-limit key MUST 使用 `remote_addr`(或等價來源)
|
||||
- **THEN** 來自 request header 的 `X-Forwarded-For` MUST 被忽略
|
||||
|
||||
#### Scenario: Deployment with trusted reverse proxy enabled
|
||||
- **WHEN** 系統明確配置 trusted proxy 名單或模式
|
||||
- **THEN** rate-limit key MAY 使用 `X-Forwarded-For` 的可信 client IP
|
||||
- **THEN** 非可信來源 MUST 回退至 `remote_addr`
|
||||
|
||||
### Requirement: Production Security Defaults SHALL Fail Safe
|
||||
生產設定在缺漏或格式錯誤時 MUST 採 fail-safe 預設,避免 API 無意外暴露或低安全模式啟動。
|
||||
|
||||
#### Scenario: page status config missing or invalid
|
||||
- **WHEN** `page_status.json` 缺失、破損或缺少 `api_public` 設定
|
||||
- **THEN** runtime MUST 預設為 API 非公開(`api_public=false`)
|
||||
- **THEN** 需要明確配置才可開啟公開 API 行為
|
||||
|
||||
#### Scenario: runtime environment variables incomplete
|
||||
- **WHEN** 生產啟動缺少關鍵安全變數(例如 `SECRET_KEY`)
|
||||
- **THEN** 系統 MUST 以安全方式拒絕啟動或進入受限模式,且輸出可診斷訊息
|
||||
|
||||
### Requirement: Sensitive Configuration Values SHALL Be Redacted in Logs
|
||||
任何含憑證的連線字串(例如 Redis URL)在 log 輸出時 MUST 進行遮罩,避免密碼外洩。
|
||||
|
||||
#### Scenario: Redis URL includes password
|
||||
- **WHEN** 應用程式記錄 Redis 連線設定
|
||||
- **THEN** log 中的 URL MUST 隱藏密碼(例如 `redis://***@host:port/db`)
|
||||
- **THEN** 原始明文密碼 MUST NOT 出現在任何應用層日誌
|
||||
|
||||
### Requirement: Released Frontend Views SHALL Avoid Unsafe Inline Interpolation
|
||||
Released 頁面前端 MUST 避免將不受信資料直接插入 inline JavaScript 或 HTML 屬性字串,降低 XSS 與 handler 斷裂風險。
|
||||
|
||||
#### Scenario: Rendering action controls with user-derived values
|
||||
- **WHEN** 前端渲染按鈕或互動控制(例如 job-query 操作欄)且內容含資料列值
|
||||
- **THEN** MUST 透過安全資料綁定(data-* attribute 或事件監聽)實作
|
||||
- **THEN** MUST NOT 依賴 raw string `onclick="...${value}..."` 拼接
|
||||
|
||||
### Requirement: Released Hardening SHALL Be Protected by Regression Gates
|
||||
本次 hardening 的行為 MUST 由自動化測試固定,並納入 CI gate,避免日後回歸。
|
||||
|
||||
#### Scenario: Negative-path regression suite execution
|
||||
- **WHEN** CI 執行 Released 頁面 API 測試
|
||||
- **THEN** MUST 覆蓋 invalid JSON、超量輸入、rate-limit、security default、與 log redaction 斷言
|
||||
- **THEN** 任一關鍵斷言失敗 MUST 阻擋合併
|
||||
|
||||
#### Scenario: Existing released behavior parity
|
||||
- **WHEN** hardening 變更部署後執行既有 Released route 測試
|
||||
- **THEN** 成功路徑與既有回應契約 MUST 維持相容
|
||||
- **THEN** 僅新增已定義的防護錯誤路徑(4xx/429)
|
||||
|
||||
@@ -114,11 +114,15 @@ def _is_production_env(app: Flask) -> bool:
|
||||
return env_value in {"prod", "production"}
|
||||
|
||||
|
||||
def _build_security_headers(production: bool) -> dict[str, str]:
|
||||
def _build_security_headers(production: bool, *, allow_unsafe_eval: bool = False) -> dict[str, str]:
|
||||
script_directives = ["'self'", "'unsafe-inline'"]
|
||||
if allow_unsafe_eval:
|
||||
script_directives.append("'unsafe-eval'")
|
||||
|
||||
headers = {
|
||||
"Content-Security-Policy": (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
|
||||
f"script-src {' '.join(script_directives)}; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data: blob:; "
|
||||
"font-src 'self' data:; "
|
||||
@@ -155,6 +159,42 @@ def _resolve_secret_key(app: Flask) -> str:
|
||||
return "dev-local-only-secret-key"
|
||||
|
||||
|
||||
def _validate_production_security_settings(app: Flask) -> None:
|
||||
"""Validate production security-sensitive runtime settings."""
|
||||
if not _is_production_env(app):
|
||||
return
|
||||
|
||||
trust_proxy_headers = resolve_bool_flag(
|
||||
"TRUST_PROXY_HEADERS",
|
||||
config=app.config,
|
||||
default=bool(app.config.get("TRUST_PROXY_HEADERS", False)),
|
||||
)
|
||||
if trust_proxy_headers:
|
||||
configured_sources = os.getenv("TRUSTED_PROXY_IPS")
|
||||
if configured_sources is None:
|
||||
configured_sources = app.config.get("TRUSTED_PROXY_IPS")
|
||||
if isinstance(configured_sources, str):
|
||||
trusted_sources = tuple(
|
||||
part.strip()
|
||||
for part in configured_sources.split(",")
|
||||
if part.strip()
|
||||
)
|
||||
else:
|
||||
trusted_sources = tuple(configured_sources or ())
|
||||
if not trusted_sources:
|
||||
raise RuntimeError(
|
||||
"TRUST_PROXY_HEADERS=true requires TRUSTED_PROXY_IPS in production."
|
||||
)
|
||||
|
||||
|
||||
def _resolve_csp_allow_unsafe_eval(app: Flask) -> bool:
|
||||
return resolve_bool_flag(
|
||||
"CSP_ALLOW_UNSAFE_EVAL",
|
||||
config=app.config,
|
||||
default=bool(app.config.get("CSP_ALLOW_UNSAFE_EVAL", False)),
|
||||
)
|
||||
|
||||
|
||||
def _resolve_portal_spa_enabled(app: Flask) -> bool:
|
||||
"""Resolve cutover flag for SPA shell navigation.
|
||||
|
||||
@@ -367,6 +407,7 @@ def create_app(config_name: str | None = None) -> Flask:
|
||||
# Session configuration with environment-aware secret validation.
|
||||
app.secret_key = _resolve_secret_key(app)
|
||||
app.config["SECRET_KEY"] = app.secret_key
|
||||
_validate_production_security_settings(app)
|
||||
|
||||
# Session cookie security settings
|
||||
# SECURE: Only send cookie over HTTPS in production.
|
||||
@@ -380,7 +421,10 @@ def create_app(config_name: str | None = None) -> Flask:
|
||||
_configure_logging(app)
|
||||
_validate_runtime_contract(app)
|
||||
_validate_in_scope_asset_readiness(app)
|
||||
security_headers = _build_security_headers(_is_production_env(app))
|
||||
security_headers = _build_security_headers(
|
||||
_is_production_env(app),
|
||||
allow_unsafe_eval=_resolve_csp_allow_unsafe_eval(app),
|
||||
)
|
||||
|
||||
# Route-level cache backend (L1 memory + optional L2 Redis)
|
||||
app.extensions["cache"] = create_default_cache_backend()
|
||||
|
||||
@@ -27,6 +27,13 @@ def _bool_env(name: str, default: bool) -> bool:
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _csv_env(name: str, default: str = "") -> tuple[str, ...]:
|
||||
value = os.getenv(name, default)
|
||||
if not value:
|
||||
return tuple()
|
||||
return tuple(item.strip() for item in value.split(",") if item.strip())
|
||||
|
||||
|
||||
class Config:
|
||||
"""Base configuration."""
|
||||
|
||||
@@ -51,6 +58,15 @@ class Config:
|
||||
CSRF_ENABLED = _bool_env("CSRF_ENABLED", True)
|
||||
PORTAL_SPA_ENABLED = _bool_env("PORTAL_SPA_ENABLED", True)
|
||||
|
||||
# Hardening configuration (safe-by-default)
|
||||
MAX_JSON_BODY_BYTES = _int_env("MAX_JSON_BODY_BYTES", 262144) # 256 KB
|
||||
QUERY_TOOL_MAX_CONTAINER_IDS = _int_env("QUERY_TOOL_MAX_CONTAINER_IDS", 200)
|
||||
RESOURCE_DETAIL_DEFAULT_LIMIT = _int_env("RESOURCE_DETAIL_DEFAULT_LIMIT", 500)
|
||||
RESOURCE_DETAIL_MAX_LIMIT = _int_env("RESOURCE_DETAIL_MAX_LIMIT", 500)
|
||||
TRUST_PROXY_HEADERS = _bool_env("TRUST_PROXY_HEADERS", False)
|
||||
TRUSTED_PROXY_IPS = _csv_env("TRUSTED_PROXY_IPS")
|
||||
CSP_ALLOW_UNSAFE_EVAL = _bool_env("CSP_ALLOW_UNSAFE_EVAL", False)
|
||||
|
||||
# Session configuration
|
||||
PERMANENT_SESSION_LIFETIME = _int_env("SESSION_LIFETIME", 28800) # 8 hours
|
||||
|
||||
@@ -117,7 +133,7 @@ class TestingConfig(Config):
|
||||
|
||||
def get_config(env: str | None = None) -> Type[Config]:
|
||||
"""Select config class based on environment name."""
|
||||
value = (env or os.getenv("FLASK_ENV", "development")).lower()
|
||||
value = (env or os.getenv("FLASK_ENV", "production")).lower()
|
||||
if value in {"prod", "production"}:
|
||||
return ProductionConfig
|
||||
if value in {"test", "testing"}:
|
||||
|
||||
@@ -6,6 +6,7 @@ from __future__ import annotations
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from ipaddress import ip_address, ip_network
|
||||
from collections import defaultdict, deque
|
||||
from functools import wraps
|
||||
from typing import Callable, Deque
|
||||
@@ -29,11 +30,66 @@ def _env_int(name: str, default: int) -> int:
|
||||
return max(value, 1)
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
raw = os.getenv(name)
|
||||
if raw is None:
|
||||
return default
|
||||
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _trusted_proxy_networks() -> list:
|
||||
raw = os.getenv("TRUSTED_PROXY_IPS", "")
|
||||
if not raw:
|
||||
return []
|
||||
|
||||
networks = []
|
||||
for token in raw.split(","):
|
||||
candidate = token.strip()
|
||||
if not candidate:
|
||||
continue
|
||||
try:
|
||||
if "/" in candidate:
|
||||
networks.append(ip_network(candidate, strict=False))
|
||||
else:
|
||||
if ":" in candidate:
|
||||
networks.append(ip_network(f"{candidate}/128", strict=False))
|
||||
else:
|
||||
networks.append(ip_network(f"{candidate}/32", strict=False))
|
||||
except ValueError:
|
||||
continue
|
||||
return networks
|
||||
|
||||
|
||||
def _is_trusted_proxy_source(remote_addr: str | None) -> bool:
|
||||
if not _env_bool("TRUST_PROXY_HEADERS", False):
|
||||
return False
|
||||
if not remote_addr:
|
||||
return False
|
||||
|
||||
networks = _trusted_proxy_networks()
|
||||
if not networks:
|
||||
# Explicit proxy trust mode requires explicit trusted source list.
|
||||
return False
|
||||
|
||||
try:
|
||||
remote_ip = ip_address(remote_addr.strip())
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
return any(remote_ip in network for network in networks)
|
||||
|
||||
|
||||
def _client_identifier() -> str:
|
||||
remote = request.remote_addr
|
||||
if _is_trusted_proxy_source(remote):
|
||||
forwarded = request.headers.get("X-Forwarded-For", "").strip()
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.remote_addr or "unknown"
|
||||
candidate = forwarded.split(",")[0].strip()
|
||||
try:
|
||||
return str(ip_address(candidate))
|
||||
except ValueError:
|
||||
pass
|
||||
return remote or "unknown"
|
||||
|
||||
|
||||
def check_and_record(
|
||||
|
||||
@@ -6,6 +6,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
import redis
|
||||
|
||||
@@ -26,6 +27,31 @@ REDIS_KEY_PREFIX = os.getenv('REDIS_KEY_PREFIX', 'mes_wip')
|
||||
_REDIS_CLIENT: Optional[redis.Redis] = None
|
||||
|
||||
|
||||
def redact_connection_url(url: str) -> str:
|
||||
"""Redact credentials in URL-like connection strings."""
|
||||
if not url:
|
||||
return url
|
||||
try:
|
||||
parsed = urlsplit(url)
|
||||
except Exception:
|
||||
return url
|
||||
|
||||
netloc = parsed.netloc
|
||||
if "@" not in netloc:
|
||||
return url
|
||||
|
||||
credentials, host = netloc.rsplit("@", 1)
|
||||
if ":" in credentials:
|
||||
user, _password = credentials.split(":", 1)
|
||||
masked = f"{user}:***" if user else "***"
|
||||
else:
|
||||
masked = "***"
|
||||
|
||||
return urlunsplit(
|
||||
(parsed.scheme, f"{masked}@{host}", parsed.path, parsed.query, parsed.fragment)
|
||||
)
|
||||
|
||||
|
||||
def get_redis_client() -> Optional[redis.Redis]:
|
||||
"""Get Redis client with connection pooling and health check.
|
||||
|
||||
@@ -50,7 +76,7 @@ def get_redis_client() -> Optional[redis.Redis]:
|
||||
)
|
||||
# Test connection
|
||||
_REDIS_CLIENT.ping()
|
||||
logger.info(f"Redis client connected to {REDIS_URL}")
|
||||
logger.info("Redis client connected to %s", redact_connection_url(REDIS_URL))
|
||||
except redis.RedisError as e:
|
||||
logger.warning(f"Failed to connect to Redis: {e}")
|
||||
_REDIS_CLIENT = None
|
||||
|
||||
64
src/mes_dashboard/core/request_validation.py
Normal file
64
src/mes_dashboard/core/request_validation.py
Normal file
@@ -0,0 +1,64 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Request validation helpers for API routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from flask import current_app, request
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JsonPayloadError:
|
||||
message: str
|
||||
status_code: int
|
||||
|
||||
|
||||
def _resolve_max_json_body_bytes(explicit_max: int | None = None) -> int:
|
||||
if explicit_max is not None:
|
||||
return max(int(explicit_max), 1)
|
||||
try:
|
||||
value = int(current_app.config.get("MAX_JSON_BODY_BYTES", 262144))
|
||||
return max(value, 1)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return max(int(os.getenv("MAX_JSON_BODY_BYTES", "262144")), 1)
|
||||
except Exception:
|
||||
return 262144
|
||||
|
||||
|
||||
def parse_json_payload(
|
||||
*,
|
||||
require_object: bool = True,
|
||||
require_non_empty_object: bool = False,
|
||||
max_body_bytes: int | None = None,
|
||||
) -> tuple[Any | None, JsonPayloadError | None]:
|
||||
"""Parse and validate JSON request payload with deterministic 4xx errors."""
|
||||
content_length = request.content_length
|
||||
max_bytes = _resolve_max_json_body_bytes(max_body_bytes)
|
||||
if content_length is not None and content_length > max_bytes:
|
||||
return None, JsonPayloadError(
|
||||
f"請求內容過大,限制 {max_bytes} bytes",
|
||||
413,
|
||||
)
|
||||
|
||||
if not request.is_json:
|
||||
return None, JsonPayloadError(
|
||||
"Content-Type 必須為 application/json",
|
||||
415,
|
||||
)
|
||||
|
||||
payload = request.get_json(silent=True)
|
||||
if payload is None:
|
||||
return None, JsonPayloadError("JSON 格式錯誤", 400)
|
||||
|
||||
if require_object and not isinstance(payload, dict):
|
||||
return None, JsonPayloadError("JSON 內容必須為物件", 400)
|
||||
|
||||
if require_non_empty_object and isinstance(payload, dict) and not payload:
|
||||
return None, JsonPayloadError("請求內容不可為空", 400)
|
||||
|
||||
return payload, None
|
||||
@@ -159,9 +159,12 @@ def api_hold_detail_lots():
|
||||
age_range = request.args.get('age_range', '').strip() or None
|
||||
include_dummy = parse_bool_query(request.args.get('include_dummy'))
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = min(request.args.get('per_page', 50, type=int), 200)
|
||||
per_page_value = request.args.get('per_page', 50, type=int)
|
||||
if per_page_value is None:
|
||||
per_page_value = 50
|
||||
per_page = min(max(per_page_value, 1), 200)
|
||||
|
||||
if page < 1:
|
||||
if page is None or page < 1:
|
||||
page = 1
|
||||
|
||||
# Validate age_range parameter
|
||||
|
||||
@@ -13,6 +13,7 @@ from flask import Blueprint, jsonify, request, Response, render_template
|
||||
|
||||
from mes_dashboard.core.rate_limit import configured_rate_limit
|
||||
from mes_dashboard.core.modernization_policy import maybe_redirect_to_canonical_shell
|
||||
from mes_dashboard.core.request_validation import parse_json_payload
|
||||
from mes_dashboard.services.job_query_service import (
|
||||
get_jobs_by_resources,
|
||||
get_job_txn_history,
|
||||
@@ -110,7 +111,9 @@ def query_jobs():
|
||||
|
||||
Returns job list.
|
||||
"""
|
||||
data = request.get_json()
|
||||
data, payload_error = parse_json_payload(require_non_empty_object=True)
|
||||
if payload_error is not None:
|
||||
return jsonify({'error': payload_error.message}), payload_error.status_code
|
||||
|
||||
resource_ids = data.get('resource_ids', [])
|
||||
start_date = data.get('start_date')
|
||||
@@ -171,7 +174,9 @@ def export_jobs():
|
||||
|
||||
Returns streaming CSV response.
|
||||
"""
|
||||
data = request.get_json()
|
||||
data, payload_error = parse_json_payload(require_non_empty_object=True)
|
||||
if payload_error is not None:
|
||||
return jsonify({'error': payload_error.message}), payload_error.status_code
|
||||
|
||||
resource_ids = data.get('resource_ids', [])
|
||||
start_date = data.get('start_date')
|
||||
|
||||
@@ -11,11 +11,12 @@ Contains Flask Blueprint for batch tracing and equipment period query endpoints:
|
||||
|
||||
import hashlib
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response, render_template
|
||||
from flask import Blueprint, jsonify, request, Response, render_template, current_app
|
||||
|
||||
from mes_dashboard.core.cache import cache_get, cache_set
|
||||
from mes_dashboard.core.modernization_policy import maybe_redirect_to_canonical_shell
|
||||
from mes_dashboard.core.rate_limit import configured_rate_limit
|
||||
from mes_dashboard.core.request_validation import parse_json_payload
|
||||
from mes_dashboard.services.query_tool_service import (
|
||||
resolve_lots,
|
||||
get_lot_history,
|
||||
@@ -86,6 +87,21 @@ _QUERY_TOOL_EXPORT_RATE_LIMIT = configured_rate_limit(
|
||||
)
|
||||
|
||||
|
||||
def _query_tool_max_container_ids() -> int:
|
||||
try:
|
||||
value = int(current_app.config.get("QUERY_TOOL_MAX_CONTAINER_IDS", 200))
|
||||
except Exception:
|
||||
value = 200
|
||||
return max(value, 1)
|
||||
|
||||
|
||||
def _reject_if_batch_too_large(container_ids: list[str]):
|
||||
max_ids = _query_tool_max_container_ids()
|
||||
if len(container_ids) <= max_ids:
|
||||
return None
|
||||
return jsonify({'error': f'container_ids 數量不可超過 {max_ids} 筆'}), 413
|
||||
|
||||
|
||||
def _format_lot_materials_export_rows(rows):
|
||||
"""Normalize LOT material export columns for UI/CSV consistency."""
|
||||
normalized_rows = []
|
||||
@@ -163,10 +179,9 @@ def resolve_lot_input():
|
||||
"not_found": ["value3"]
|
||||
}
|
||||
"""
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return jsonify({'error': '請求內容不可為空'}), 400
|
||||
data, payload_error = parse_json_payload(require_non_empty_object=True)
|
||||
if payload_error is not None:
|
||||
return jsonify({'error': payload_error.message}), payload_error.status_code
|
||||
|
||||
input_type = data.get('input_type')
|
||||
values = data.get('values', [])
|
||||
@@ -240,6 +255,9 @@ def query_lot_history():
|
||||
cids = [c.strip() for c in container_ids_param.split(',') if c.strip()]
|
||||
if not cids:
|
||||
return jsonify({'error': '請指定 CONTAINERID'}), 400
|
||||
too_large = _reject_if_batch_too_large(cids)
|
||||
if too_large is not None:
|
||||
return too_large
|
||||
result = get_lot_history_batch(cids, workcenter_groups=workcenter_groups)
|
||||
elif container_id:
|
||||
result = get_lot_history(container_id, workcenter_groups=workcenter_groups)
|
||||
@@ -319,6 +337,9 @@ def query_lot_associations():
|
||||
cids = [c.strip() for c in container_ids_param.split(',') if c.strip()]
|
||||
if not cids:
|
||||
return jsonify({'error': '請指定 CONTAINERID'}), 400
|
||||
too_large = _reject_if_batch_too_large(cids)
|
||||
if too_large is not None:
|
||||
return too_large
|
||||
result = get_lot_associations_batch(cids, assoc_type)
|
||||
else:
|
||||
if not container_id:
|
||||
@@ -369,10 +390,9 @@ def query_equipment_period():
|
||||
|
||||
Returns data based on query_type.
|
||||
"""
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return jsonify({'error': '請求內容不可為空'}), 400
|
||||
data, payload_error = parse_json_payload(require_non_empty_object=True)
|
||||
if payload_error is not None:
|
||||
return jsonify({'error': payload_error.message}), payload_error.status_code
|
||||
|
||||
equipment_ids = data.get('equipment_ids', [])
|
||||
equipment_names = data.get('equipment_names', [])
|
||||
@@ -511,10 +531,9 @@ def export_csv():
|
||||
|
||||
Returns streaming CSV response.
|
||||
"""
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return jsonify({'error': '請求內容不可為空'}), 400
|
||||
data, payload_error = parse_json_payload(require_non_empty_object=True)
|
||||
if payload_error is not None:
|
||||
return jsonify({'error': payload_error.message}), payload_error.status_code
|
||||
|
||||
export_type = data.get('export_type')
|
||||
params = data.get('params', {})
|
||||
|
||||
@@ -6,7 +6,7 @@ Contains Flask Blueprint for resource/equipment-related API endpoints.
|
||||
|
||||
import math
|
||||
import logging
|
||||
from flask import Blueprint, jsonify, request
|
||||
from flask import Blueprint, jsonify, request, current_app
|
||||
|
||||
from mes_dashboard.core.database import (
|
||||
get_db_connection,
|
||||
@@ -15,6 +15,7 @@ from mes_dashboard.core.database import (
|
||||
)
|
||||
from mes_dashboard.core.cache import cache_get, cache_set, make_cache_key
|
||||
from mes_dashboard.core.rate_limit import configured_rate_limit
|
||||
from mes_dashboard.core.request_validation import parse_json_payload
|
||||
from mes_dashboard.core.response import INTERNAL_ERROR, error_response
|
||||
from mes_dashboard.core.utils import get_days_back, parse_bool_query
|
||||
|
||||
@@ -147,6 +148,14 @@ def _optional_bool_arg(name: str):
|
||||
return parse_bool_query(text)
|
||||
|
||||
|
||||
def _config_int(name: str, default: int, minimum: int = 1) -> int:
|
||||
try:
|
||||
parsed = int(current_app.config.get(name, default))
|
||||
except Exception:
|
||||
parsed = int(default)
|
||||
return max(parsed, minimum)
|
||||
|
||||
|
||||
@resource_bp.route('/by_status')
|
||||
def api_resource_by_status():
|
||||
"""API: Resource count by status."""
|
||||
@@ -205,10 +214,33 @@ def api_resource_workcenter_status_matrix():
|
||||
@_RESOURCE_DETAIL_RATE_LIMIT
|
||||
def api_resource_detail():
|
||||
"""API: Resource detail with filters."""
|
||||
data = request.get_json() or {}
|
||||
data, payload_error = parse_json_payload(require_object=True)
|
||||
if payload_error is not None:
|
||||
return jsonify({'success': False, 'error': payload_error.message}), payload_error.status_code
|
||||
|
||||
filters = data.get('filters')
|
||||
limit = data.get('limit', 500)
|
||||
offset = data.get('offset', 0)
|
||||
if filters is not None and not isinstance(filters, dict):
|
||||
return jsonify({'success': False, 'error': 'filters 必須為物件'}), 400
|
||||
|
||||
default_limit = _config_int("RESOURCE_DETAIL_DEFAULT_LIMIT", 500)
|
||||
max_limit = _config_int("RESOURCE_DETAIL_MAX_LIMIT", default_limit)
|
||||
|
||||
try:
|
||||
limit = int(data.get('limit', default_limit))
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({'success': False, 'error': 'limit 必須為整數'}), 400
|
||||
if limit < 1:
|
||||
return jsonify({'success': False, 'error': 'limit 必須大於 0'}), 400
|
||||
if limit > max_limit:
|
||||
return jsonify({'success': False, 'error': f'limit 不可超過 {max_limit}'}), 413
|
||||
|
||||
try:
|
||||
offset = int(data.get('offset', 0))
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({'success': False, 'error': 'offset 必須為整數'}), 400
|
||||
if offset < 0:
|
||||
return jsonify({'success': False, 'error': 'offset 不可小於 0'}), 400
|
||||
|
||||
days_back = get_days_back(filters)
|
||||
|
||||
df = query_resource_detail(filters, limit, offset, days_back)
|
||||
|
||||
@@ -371,7 +371,10 @@ def api_meta_search():
|
||||
"""
|
||||
search_field = request.args.get('field', '').strip().lower()
|
||||
q = request.args.get('q', '').strip()
|
||||
limit = min(request.args.get('limit', 20, type=int), 50)
|
||||
limit_value = request.args.get('limit', 20, type=int)
|
||||
if limit_value is None:
|
||||
limit_value = 20
|
||||
limit = min(max(limit_value, 1), 50)
|
||||
include_dummy = parse_bool_query(request.args.get('include_dummy'))
|
||||
|
||||
# Cross-filter parameters
|
||||
|
||||
@@ -88,11 +88,11 @@ def _load() -> dict:
|
||||
logger.debug("Loaded page status from %s", DATA_FILE)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning("Failed to load page status: %s", e)
|
||||
_cache = {"pages": [], "api_public": True}
|
||||
_cache = {"pages": [], "api_public": False}
|
||||
_cache_mtime = 0.0
|
||||
else:
|
||||
logger.info("Page status file not found, using defaults")
|
||||
_cache = {"pages": [], "api_public": True}
|
||||
_cache = {"pages": [], "api_public": False}
|
||||
_cache_mtime = 0.0
|
||||
|
||||
if _migrate_navigation_schema(_cache):
|
||||
@@ -487,7 +487,14 @@ def is_api_public() -> bool:
|
||||
True if API endpoints bypass permission checks
|
||||
"""
|
||||
with _lock:
|
||||
return _load().get("api_public", True)
|
||||
value = _load().get("api_public", False)
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
return False
|
||||
|
||||
|
||||
def reload_cache() -> None:
|
||||
|
||||
@@ -18,6 +18,7 @@ Architecture:
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
@@ -49,6 +50,13 @@ DEFAULT_TIME_WINDOW_HOURS = 168 # 1 week for better PJ_TYPE detection
|
||||
ADJACENT_LOTS_COUNT = 3
|
||||
|
||||
|
||||
def _max_batch_container_ids() -> int:
|
||||
try:
|
||||
return max(int(os.getenv("QUERY_TOOL_MAX_CONTAINER_IDS", "200")), 1)
|
||||
except (TypeError, ValueError):
|
||||
return 200
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Validation Functions
|
||||
# ============================================================
|
||||
@@ -881,6 +889,9 @@ def get_lot_history_batch(
|
||||
"""
|
||||
if not container_ids:
|
||||
return {'error': '請指定 CONTAINERID'}
|
||||
max_ids = _max_batch_container_ids()
|
||||
if len(container_ids) > max_ids:
|
||||
return {'error': f'container_ids 數量不可超過 {max_ids} 筆'}
|
||||
|
||||
try:
|
||||
events_by_cid = EventFetcher.fetch_events(container_ids, "history")
|
||||
@@ -933,6 +944,9 @@ def get_lot_associations_batch(
|
||||
"""
|
||||
if not container_ids:
|
||||
return {'error': '請指定 CONTAINERID'}
|
||||
max_ids = _max_batch_container_ids()
|
||||
if len(container_ids) > max_ids:
|
||||
return {'error': f'container_ids 數量不可超過 {max_ids} 筆'}
|
||||
|
||||
valid_batch_types = {'materials', 'rejects', 'holds'}
|
||||
if assoc_type not in valid_batch_types:
|
||||
|
||||
@@ -5,6 +5,7 @@ Provides functions to query equipment status from DWH.DW_MES_RESOURCE and DWH.DW
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import pandas as pd
|
||||
from typing import Optional, Dict, List, Any
|
||||
|
||||
@@ -173,6 +174,22 @@ def query_resource_detail(
|
||||
DataFrame with resource details or None if query fails.
|
||||
"""
|
||||
try:
|
||||
try:
|
||||
max_limit = max(int(os.getenv("RESOURCE_DETAIL_MAX_LIMIT", "500")), 1)
|
||||
except (TypeError, ValueError):
|
||||
max_limit = 500
|
||||
try:
|
||||
limit = int(limit)
|
||||
except (TypeError, ValueError):
|
||||
limit = 500
|
||||
try:
|
||||
offset = int(offset)
|
||||
except (TypeError, ValueError):
|
||||
offset = 0
|
||||
|
||||
limit = max(1, min(limit, max_limit))
|
||||
offset = max(offset, 0)
|
||||
|
||||
base_sql = get_resource_latest_status_subquery(days_back)
|
||||
|
||||
# Use QueryBuilder for safe parameterized conditions
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -311,6 +311,34 @@ class TestHoldDetailLotsRoute(TestHoldRoutesBase):
|
||||
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):
|
||||
"""Should return success=False and 500 on failure."""
|
||||
|
||||
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
|
||||
@@ -90,6 +90,43 @@ class TestGetResources:
|
||||
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."""
|
||||
response = client.post(
|
||||
@@ -259,6 +296,30 @@ class TestQueryJobTxnHistory:
|
||||
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."""
|
||||
response = client.post(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -56,6 +56,30 @@ class TestQueryToolPage:
|
||||
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."""
|
||||
response = client.post(
|
||||
@@ -280,6 +304,15 @@ class TestLotHistoryEndpoint:
|
||||
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:
|
||||
"""Tests for /api/query-tool/adjacent-lots endpoint."""
|
||||
@@ -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."""
|
||||
@@ -535,6 +579,30 @@ class TestQueryToolRateLimit:
|
||||
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."""
|
||||
response = client.post(
|
||||
@@ -663,6 +731,30 @@ class TestEquipmentPeriodEndpoint:
|
||||
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."""
|
||||
response = client.post(
|
||||
|
||||
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"
|
||||
@@ -91,6 +91,34 @@ class TestRedisClient:
|
||||
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:
|
||||
"""Test Redis client singleton behavior."""
|
||||
|
||||
@@ -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