Promote /tables, /excel-query, /query-tool, /mid-section-defect from deferred to full shell-governed in-scope routes with canonical redirects, content contracts, governance artifacts, and updated CI gates. Unify all page header gradients to #667eea → #764ba2 and h1 font-size to 24px for visual consistency across all dashboard pages. Remove Native Route-View dev annotations from job-query, excel-query, and query-tool headers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
371 lines
13 KiB
Python
371 lines
13 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""End-to-end tests for admin authentication flow.
|
|
|
|
These tests simulate real user workflows through the admin authentication system.
|
|
Run with: pytest tests/e2e/test_admin_auth_e2e.py -v --run-integration
|
|
"""
|
|
|
|
import json
|
|
import pytest
|
|
from unittest.mock import patch
|
|
|
|
import sys
|
|
import os
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', 'src'))
|
|
|
|
import mes_dashboard.core.database as db
|
|
from mes_dashboard.app import create_app
|
|
from mes_dashboard.services import page_registry
|
|
from mes_dashboard.routes import auth_routes
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_page_status(tmp_path):
|
|
"""Create temporary page status file."""
|
|
data_file = tmp_path / "page_status.json"
|
|
initial_data = {
|
|
"pages": [
|
|
{"route": "/", "name": "首頁", "status": "released"},
|
|
{"route": "/wip-overview", "name": "WIP 即時概況", "status": "released"},
|
|
{"route": "/wip-detail", "name": "WIP 明細", "status": "released"},
|
|
{"route": "/tables", "name": "表格總覽", "status": "dev"},
|
|
{"route": "/resource", "name": "機台狀態", "status": "dev"},
|
|
],
|
|
"api_public": True
|
|
}
|
|
data_file.write_text(json.dumps(initial_data, ensure_ascii=False), encoding="utf-8")
|
|
return data_file
|
|
|
|
|
|
@pytest.fixture
|
|
def app(temp_page_status):
|
|
"""Create application for testing."""
|
|
db._ENGINE = None
|
|
|
|
# Mock page registry
|
|
original_data_file = page_registry.DATA_FILE
|
|
original_cache = page_registry._cache
|
|
page_registry.DATA_FILE = temp_page_status
|
|
page_registry._cache = None
|
|
|
|
app = create_app('testing')
|
|
app.config['TESTING'] = True
|
|
app.config['CSRF_ENABLED'] = False
|
|
|
|
yield app
|
|
|
|
page_registry.DATA_FILE = original_data_file
|
|
page_registry._cache = original_cache
|
|
|
|
|
|
@pytest.fixture
|
|
def client(app):
|
|
"""Create test client."""
|
|
return app.test_client()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clear_login_rate_limit():
|
|
"""Reset in-memory login attempts to avoid cross-test interference."""
|
|
auth_routes._login_attempts.clear()
|
|
yield
|
|
auth_routes._login_attempts.clear()
|
|
|
|
|
|
def _mock_admin_user(mail: str = "ymirliu@panjit.com.tw") -> dict:
|
|
return {
|
|
"username": "92367",
|
|
"displayName": "Test Admin",
|
|
"mail": mail,
|
|
"department": "Test Department",
|
|
}
|
|
|
|
|
|
class TestFullLoginLogoutFlow:
|
|
"""E2E tests for complete login/logout flow."""
|
|
|
|
@patch('mes_dashboard.routes.auth_routes.is_admin', return_value=True)
|
|
@patch('mes_dashboard.routes.auth_routes.authenticate')
|
|
def test_complete_admin_login_workflow(self, mock_auth, _mock_is_admin, client):
|
|
"""Test complete admin login workflow."""
|
|
mock_auth.return_value = _mock_admin_user()
|
|
spa_enabled = bool(client.application.config.get("PORTAL_SPA_ENABLED", False))
|
|
|
|
# 1. Access portal entry.
|
|
response = client.get("/", follow_redirects=False)
|
|
if spa_enabled:
|
|
assert response.status_code == 302
|
|
assert response.location.rstrip("/").endswith("/portal-shell")
|
|
else:
|
|
assert response.status_code == 200
|
|
content = response.data.decode("utf-8")
|
|
assert "管理員登入" in content
|
|
|
|
# 2. Go to login page
|
|
response = client.get("/admin/login")
|
|
assert response.status_code == 200
|
|
|
|
# 3. Submit login form
|
|
response = client.post("/admin/login", data={
|
|
"username": "92367",
|
|
"password": "password123"
|
|
}, follow_redirects=True)
|
|
|
|
assert response.status_code == 200
|
|
if not spa_enabled:
|
|
content = response.data.decode("utf-8")
|
|
# Portal template should show admin identity in non-SPA mode.
|
|
assert "Test Admin" in content or "登出" in content
|
|
|
|
# 4. Verify session has admin
|
|
with client.session_transaction() as sess:
|
|
assert "admin" in sess
|
|
assert sess["admin"]["mail"] == "ymirliu@panjit.com.tw"
|
|
|
|
if spa_enabled:
|
|
nav = client.get("/api/portal/navigation")
|
|
assert nav.status_code == 200
|
|
assert nav.get_json()["is_admin"] is True
|
|
|
|
# 5. Access admin pages
|
|
response = client.get("/admin/pages")
|
|
assert response.status_code == 200
|
|
|
|
# 6. Logout
|
|
response = client.get("/admin/logout", follow_redirects=True)
|
|
assert response.status_code == 200
|
|
|
|
# 7. Verify logged out
|
|
with client.session_transaction() as sess:
|
|
assert "admin" not in sess
|
|
|
|
# 8. Admin pages should redirect now
|
|
response = client.get("/admin/pages", follow_redirects=False)
|
|
assert response.status_code == 302
|
|
|
|
|
|
class TestPageAccessControlFlow:
|
|
"""E2E tests for page access control flow."""
|
|
|
|
def test_non_admin_cannot_access_dev_pages(self, client, temp_page_status):
|
|
"""Test non-admin users cannot access dev pages."""
|
|
# 1. Access released page - should work
|
|
response = client.get("/wip-overview")
|
|
assert response.status_code != 403
|
|
|
|
# 2. Access dev page - should get 403
|
|
response = client.get("/tables")
|
|
assert response.status_code == 403
|
|
content = response.data.decode("utf-8")
|
|
assert "開發中" in content or "403" in content
|
|
|
|
@patch('mes_dashboard.routes.auth_routes.is_admin', return_value=True)
|
|
@patch('mes_dashboard.routes.auth_routes.authenticate')
|
|
def test_admin_can_access_all_pages(self, mock_auth, _mock_is_admin, client, temp_page_status):
|
|
"""Test admin users can access all pages."""
|
|
mock_auth.return_value = _mock_admin_user()
|
|
|
|
# 1. Login as admin
|
|
client.post("/admin/login", data={
|
|
"username": "92367",
|
|
"password": "password123"
|
|
})
|
|
|
|
# 2. Access released page - should work
|
|
response = client.get("/wip-overview")
|
|
assert response.status_code != 403
|
|
|
|
# 3. Access dev page - should work for admin
|
|
response = client.get("/tables")
|
|
assert response.status_code != 403
|
|
|
|
|
|
class TestPageManagementFlow:
|
|
"""E2E tests for page management flow."""
|
|
|
|
@patch('mes_dashboard.routes.auth_routes.is_admin', return_value=True)
|
|
@patch('mes_dashboard.routes.auth_routes.authenticate')
|
|
def test_admin_can_change_page_status(self, mock_auth, _mock_is_admin, client, temp_page_status):
|
|
"""Test admin can change page status via management interface."""
|
|
mock_auth.return_value = _mock_admin_user()
|
|
|
|
# 1. Login as admin
|
|
client.post("/admin/login", data={
|
|
"username": "92367",
|
|
"password": "password123"
|
|
})
|
|
|
|
# 2. Get current pages list
|
|
response = client.get("/admin/api/pages")
|
|
assert response.status_code == 200
|
|
data = json.loads(response.data)
|
|
assert data["success"] is True
|
|
|
|
# 3. Change /wip-overview from released to dev
|
|
response = client.put(
|
|
"/admin/api/pages/wip-overview",
|
|
data=json.dumps({"status": "dev"}),
|
|
content_type="application/json"
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# 4. Verify change persisted
|
|
page_registry._cache = None
|
|
status = page_registry.get_page_status("/wip-overview")
|
|
assert status == "dev"
|
|
|
|
# 5. Logout
|
|
client.get("/admin/logout")
|
|
|
|
# 6. Now non-admin should get 403 on /wip-overview
|
|
response = client.get("/wip-overview")
|
|
assert response.status_code == 403
|
|
|
|
@patch('mes_dashboard.routes.auth_routes.is_admin', return_value=True)
|
|
@patch('mes_dashboard.routes.auth_routes.authenticate')
|
|
def test_release_dev_page_makes_it_public(self, mock_auth, _mock_is_admin, client, temp_page_status):
|
|
"""Test releasing a dev page makes it publicly accessible."""
|
|
mock_auth.return_value = _mock_admin_user()
|
|
|
|
# 1. Verify /tables is currently dev (403 for non-admin)
|
|
response = client.get("/tables")
|
|
assert response.status_code == 403
|
|
|
|
# 2. Login as admin
|
|
client.post("/admin/login", data={
|
|
"username": "92367",
|
|
"password": "password123"
|
|
})
|
|
|
|
# 3. Release the page
|
|
response = client.put(
|
|
"/admin/api/pages/tables",
|
|
data=json.dumps({"status": "released"}),
|
|
content_type="application/json"
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# 4. Logout
|
|
client.get("/admin/logout")
|
|
|
|
# 5. Clear cache and verify non-admin can access
|
|
page_registry._cache = None
|
|
response = client.get("/tables")
|
|
assert response.status_code != 403
|
|
|
|
|
|
class TestPortalDynamicTabs:
|
|
"""E2E tests for dynamic portal tabs based on page status."""
|
|
|
|
def test_portal_hides_dev_tabs_for_non_admin(self, client, temp_page_status):
|
|
"""Test portal hides dev page tabs for non-admin users."""
|
|
response = client.get("/api/portal/navigation")
|
|
assert response.status_code == 200
|
|
payload = response.get_json()
|
|
routes = {
|
|
page["route"]
|
|
for drawer in payload.get("drawers", [])
|
|
for page in drawer.get("pages", [])
|
|
}
|
|
assert "/wip-overview" in routes
|
|
assert "/tables" not in routes
|
|
assert payload.get("is_admin") is False
|
|
|
|
@patch('mes_dashboard.routes.auth_routes.is_admin', return_value=True)
|
|
@patch('mes_dashboard.routes.auth_routes.authenticate')
|
|
def test_portal_shows_all_tabs_for_admin(self, mock_auth, _mock_is_admin, client, temp_page_status):
|
|
"""Test portal shows all tabs for admin users."""
|
|
mock_auth.return_value = _mock_admin_user()
|
|
|
|
# Login as admin
|
|
client.post("/admin/login", data={
|
|
"username": "92367",
|
|
"password": "password123"
|
|
})
|
|
|
|
response = client.get("/api/portal/navigation")
|
|
assert response.status_code == 200
|
|
payload = response.get_json()
|
|
routes = {
|
|
page["route"]
|
|
for drawer in payload.get("drawers", [])
|
|
for page in drawer.get("pages", [])
|
|
}
|
|
assert "/wip-overview" in routes
|
|
assert "/tables" in routes
|
|
assert payload.get("is_admin") is True
|
|
|
|
|
|
class TestSessionPersistence:
|
|
"""E2E tests for session persistence."""
|
|
|
|
@patch('mes_dashboard.routes.auth_routes.is_admin', return_value=True)
|
|
@patch('mes_dashboard.routes.auth_routes.authenticate')
|
|
def test_session_persists_across_requests(self, mock_auth, _mock_is_admin, client):
|
|
"""Test admin session persists across multiple requests."""
|
|
mock_auth.return_value = _mock_admin_user()
|
|
|
|
# Login
|
|
client.post("/admin/login", data={
|
|
"username": "92367",
|
|
"password": "password123"
|
|
})
|
|
|
|
# Make multiple requests
|
|
for _ in range(5):
|
|
response = client.get("/admin/pages")
|
|
assert response.status_code == 200
|
|
|
|
# Session should still be valid
|
|
with client.session_transaction() as sess:
|
|
assert "admin" in sess
|
|
|
|
|
|
class TestSecurityScenarios:
|
|
"""E2E tests for security scenarios."""
|
|
|
|
def test_cannot_access_admin_api_without_login(self, client):
|
|
"""Test admin APIs are protected."""
|
|
# Try to get pages without login
|
|
response = client.get("/admin/api/pages", follow_redirects=False)
|
|
assert response.status_code in (302, 401)
|
|
|
|
# Try to update page without login
|
|
response = client.put(
|
|
"/admin/api/pages/wip-overview",
|
|
data=json.dumps({"status": "dev"}),
|
|
content_type="application/json",
|
|
follow_redirects=False
|
|
)
|
|
assert response.status_code in (302, 401)
|
|
|
|
@patch('mes_dashboard.routes.auth_routes.is_admin', return_value=False)
|
|
@patch('mes_dashboard.routes.auth_routes.authenticate')
|
|
def test_non_admin_user_cannot_login(self, mock_auth, _mock_is_admin, client):
|
|
"""Test non-admin user cannot access admin features."""
|
|
mock_auth.return_value = {
|
|
"username": "99999",
|
|
"displayName": "Regular User",
|
|
"mail": "regular@panjit.com.tw",
|
|
"department": "Test",
|
|
}
|
|
|
|
# Try to login
|
|
response = client.post("/admin/login", data={
|
|
"username": "99999",
|
|
"password": "password123"
|
|
})
|
|
|
|
# Should fail (show error, not redirect)
|
|
assert response.status_code == 200
|
|
content = response.data.decode("utf-8")
|
|
assert "管理員" in content or "error" in content.lower()
|
|
|
|
# Should NOT have admin session
|
|
with client.session_transaction() as sess:
|
|
assert "admin" not in sess
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v"])
|