from __future__ import annotations from pathlib import Path import httpx import pytest from app.main import create_app ROOT = Path(__file__).resolve().parents[3] PORTAL = ROOT / "app" / "static" / "portal" @pytest.mark.asyncio async def test_portal_root_redirects_to_public_home() -> None: transport = httpx.ASGITransport(app=create_app()) async with httpx.AsyncClient( transport=transport, base_url="http://test", follow_redirects=False ) as client: response = await client.get("/") assert response.status_code in {302, 307} assert response.headers["location"] == "/portal/guest/home/" @pytest.mark.asyncio @pytest.mark.parametrize( "path", [ "/portal/guest/home/", "/portal/guest/products/", "/portal/guest/product-detail/?code=510300", "/portal/customer/login/", "/portal/customer/dashboard/", "/portal/customer/holdings/", "/portal/customer/profit-loss/", "/portal/customer/orders/", "/portal/customer/transactions/", "/portal/customer/cash-ledger/", "/portal/customer/risk-questionnaire/", "/portal/employee-console/login/", "/portal/employee-console/workspace/", "/portal/employee-risk/dashboard/", ], ) async def test_public_portal_pages_are_served(path: str) -> None: transport = httpx.ASGITransport(app=create_app()) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: response = await client.get(path) assert response.status_code == 200 assert 'lang="zh-CN"' in response.text assert '' in response.text def test_every_portal_page_has_local_js_and_css_entry() -> None: pages = list(PORTAL.glob("*/*/index.html")) assert pages for page in pages: page_name = page.parent.name assert (page.parent / f"{page_name}.js").is_file(), page assert (page.parent / f"{page_name}.css").is_file(), page def test_business_pages_do_not_call_fetch_directly() -> None: direct_fetch_files = [ path.relative_to(PORTAL).as_posix() for path in PORTAL.rglob("*.js") if "fetch(" in path.read_text(encoding="utf-8") ] assert direct_fetch_files == ["common/api-client.js"] def test_api_client_registers_all_trading_endpoint_ids() -> None: source = (PORTAL / "common" / "api-client.js").read_text(encoding="utf-8") for endpoint_id in ("T001", "T002", "T003", "T004", "T005", "T006", "T007", "T008", "T009"): assert f"{endpoint_id}:" in source def test_api_client_registers_onboarding_risk_and_admin_endpoints() -> None: source = (PORTAL / "common" / "api-client.js").read_text(encoding="utf-8") for endpoint_id in ( "ONB001", "ONB002", "RK001", "RK002", "RK003", "RK004", "RK005", "RK006", "RK007", "RK008", "RK009", "RK010", "RK011", "RK012", "RK013", "RK014", "RK015", "A002", "A003", "A004", "A005", "A006", "A012", "A033", "A035", "A036", "A037", "A038", "A039", "A040", ): assert f"{endpoint_id}:" in source def test_customer_questionnaire_uses_server_contract() -> None: source = (PORTAL / "customer" / "risk-questionnaire" / "risk-questionnaire.js").read_text( encoding="utf-8" ) assert "ONB001" in source assert "ONB002" in source assert "declaration_accepted: true" in source assert "total_score" not in source def test_questionnaire_is_customer_only_and_auth_context_matches_token() -> None: auth = (PORTAL / "common" / "auth.js").read_text(encoding="utf-8") login = (PORTAL / "common" / "login-controller.js").read_text(encoding="utf-8") questionnaire = ( PORTAL / "customer" / "risk-questionnaire" / "risk-questionnaire.js" ).read_text(encoding="utf-8") assert "export function requireCustomerOnly()" in auth assert "IDENTITY_COOKIE = 'portal_auth_user'" in auth assert "readCookie(IDENTITY_COOKIE) === String(context.userId)" in auth assert "requireCustomerOnly()" in questionnaire assert "? ['customer']" in login def test_portal_auth_supports_cross_tab_logout_and_account_switching() -> None: auth = (PORTAL / "common" / "auth.js").read_text(encoding="utf-8") shell = (PORTAL / "common" / "layout" / "app-shell.js").read_text( encoding="utf-8" ) login = (PORTAL / "common" / "login-controller.js").read_text( encoding="utf-8" ) assert "new BroadcastChannel(AUTH_CHANNEL_NAME)" in auth assert "CONTEXT_COOKIE = 'portal_auth_context'" in auth assert "readSessionContext() || readSharedContext()" in auth assert "signed-in" in auth assert "signed-out" in auth assert "account-switched" in auth assert "postMessage({ type })" in auth assert "type === 'signed-in'" in auth assert "hasMatchingIdentity(readCookie('auth_token'), context)" in auth broadcast_line = next(line for line in auth.splitlines() if "postMessage" in line) assert "access_token" not in broadcast_line assert "data-switch-account" in shell assert "data-logout" in shell assert "切换账号" in shell assert "退出登录" in shell assert "REASON_MESSAGES" in login def test_risk_workspace_covers_documented_modules() -> None: html = (PORTAL / "employee-risk" / "dashboard" / "index.html").read_text(encoding="utf-8") source = (PORTAL / "employee-risk" / "dashboard" / "dashboard.js").read_text(encoding="utf-8") permissions = (PORTAL / "common" / "permission-codes.js").read_text(encoding="utf-8") for label in ("预警队列", "证据查询", "通知记录", "风控助手", "风险日报"): assert label in html combined = html + source + permissions for permission in ( "risk:alert:read", "risk:alert:write", "risk:alert:scan", "risk:report:mail", ): assert permission in combined def test_admin_workspace_is_not_an_identity_placeholder() -> None: html = (PORTAL / "employee-console" / "workspace" / "index.html").read_text(encoding="utf-8") assert "只展示服务端确认的身份边界" not in html for label in ("角色与权限", "配置与模型", "审计记录", "转人工工单", "画像候选"): assert label in html def test_frontend_has_no_remote_scripts_or_token_local_storage() -> None: sources = "\n".join( path.read_text(encoding="utf-8") for path in PORTAL.rglob("*") if path.is_file() and path.suffix in {".html", ".js", ".css"} ) assert '