from __future__ import annotations from typing import Any import httpx import pytest from fastapi.testclient import TestClient from server.auth.providers import AuthError, JmsAuthProvider from server.integrations.jms_auth_client import JmsAuthClient, JmsAuthClientError class FakeJmsAuthClient: def __init__(self) -> None: self.login_payload: dict[str, Any] | None = None self.logged_out = False self.action = "" self.user_state = "0" self.tenant_state = "0" async def precheck(self, *, tenant_code: str, username: str = "") -> dict[str, Any]: assert tenant_code == "tenant-code" return {"requireCaptcha": username == "captcha-user"} async def tenants(self) -> list[dict[str, Any]]: return [ { "code": "tenant-code", "name": "测试租户", "state": "0", "deleted": 0, "contact": "不得透传", }, {"code": "disabled", "name": "停用租户", "state": "1", "deleted": 0}, ] async def captcha(self) -> dict[str, str]: return {"captchaId": "captcha-id", "image": "data:image/png;base64,AA=="} async def login(self, **payload: Any) -> dict[str, Any]: self.login_payload = payload return { "tokenName": "X-JMS-Token", "tokenValue": "remote-token", "action": self.action, } async def profile(self, *, token_name: str, token_value: str) -> dict[str, Any]: assert (token_name, token_value) == ("X-JMS-Token", "remote-token") return { "id": "9007199254740001", "username": "operator", "fullname": "计划员", "state": self.user_state, "roles": [{"code": "planner"}, {"code": "scheduler"}], } async def tenant(self, *, token_name: str, token_value: str) -> dict[str, Any]: assert (token_name, token_value) == ("X-JMS-Token", "remote-token") return {"uuid": "tenant-uuid-001", "code": "tenant-code", "state": self.tenant_state} async def logout(self, *, token_name: str, token_value: str) -> None: assert (token_name, token_value) == ("X-JMS-Token", "remote-token") self.logged_out = True @pytest.fixture() def jms_provider(monkeypatch) -> tuple[JmsAuthProvider, FakeJmsAuthClient]: monkeypatch.delenv("JMS_AUTH_TENANT_CODE", raising=False) monkeypatch.delenv("JMS_AUTH_TENANT_NAME", raising=False) monkeypatch.setenv("JMS_AUTH_SESSION_SECRET", "test-session-secret-with-at-least-32-chars") fake = FakeJmsAuthClient() return JmsAuthProvider(client=fake), fake # type: ignore[arg-type] def test_gateway_uses_real_login_contract_and_revalidates_session(jms_provider, monkeypatch): provider, fake = jms_provider import server.auth.middleware as auth_middleware import server.gateway.app as gateway_app monkeypatch.setattr(auth_middleware, "get_auth_provider", lambda: provider) monkeypatch.setattr(gateway_app, "get_auth_provider", lambda: provider) client = TestClient(gateway_app.create_app()) tenants_resp = client.get("/api/auth/tenants") assert tenants_resp.status_code == 410 assert "企业名称" in tenants_resp.json()["error"]["message"] assert client.get("/api/auth/login/precheck", params={ "username": "operator", "tenantName": "测试租户", "tenantCode": "tenant-code", }).json() == { "requireCaptcha": False, } assert client.get("/api/auth/captcha").json() == { "captchaId": "captcha-id", "image": "data:image/png;base64,AA==", } response = client.post("/api/auth/login", json={ "method": "password", "tenantName": "测试租户", "tenantCode": "tenant-code", "username": "operator", "password": "secret", "captchaId": "captcha-id", "captchaCode": "A7K9", }) assert response.status_code == 200 assert response.json()["user"] == { "user_id": "9007199254740001", "username": "operator", "fullname": "计划员", "tenant_uuid": "tenant-uuid-001", "roles": ["planner", "scheduler"], "expires_at": None, "auth_kind": "user", "license_type": None, "license_activated_at": None, "license_expires_at": None, "activation_id": None, } assert fake.login_payload == { "tenant_code": "tenant-code", "tenant_name": "测试租户", "username": "operator", "password": "secret", "captcha_id": "captcha-id", "captcha_code": "A7K9", } assert client.get("/api/auth/me").status_code == 200 assert client.post("/api/auth/refresh").status_code == 200 assert client.post("/api/auth/logout").status_code == 200 assert fake.logged_out is True assert client.get("/api/auth/me").status_code == 401 @pytest.mark.asyncio async def test_provider_rejects_disabled_user_and_required_security_action(jms_provider): provider, fake = jms_provider fake.user_state = "1" with pytest.raises(AuthError, match="用户已被停用") as disabled: await provider.login({ "method": "password", "tenantCode": "tenant-code", "username": "operator", "password": "secret", }) assert disabled.value.status_code == 403 fake.user_state = "0" fake.action = "changePassword" with pytest.raises(AuthError, match="changePassword") as action: await provider.login({ "method": "password", "tenantCode": "tenant-code", "username": "operator", "password": "secret", }) assert action.value.status_code == 409 assert fake.logged_out is True @pytest.mark.asyncio async def test_jms_http_client_sends_documented_query_body_and_token_header(): requests: list[httpx.Request] = [] def handler(request: httpx.Request) -> httpx.Response: requests.append(request) path = request.url.path if path == "/admin/index/tenant": return httpx.Response(200, json={"code": 0, "data": [{ "code": "tenant-code", "name": "测试租户", "state": 0, }]}) if path == "/admin/index/login/precheck": return httpx.Response(200, json={"code": 0, "data": {"requireCaptcha": False}}) if path == "/admin/index/captcha": return httpx.Response(200, json={"code": 0, "data": { "captchaId": "cid", "image": "data:image/png;base64,AA==", }}) if path == "/admin/index/login": return httpx.Response(200, json={"code": 0, "data": { "tokenName": "X-JMS-Token", "tokenValue": "remote-token", }}) if path == "/admin/auth/profile": return httpx.Response(200, json={"code": 0, "data": {"id": "1"}}) if path == "/admin/auth/tenant": return httpx.Response(200, json={"code": 0, "data": {"uuid": "tenant"}}) if path == "/admin/auth/logout": return httpx.Response(200, json={"code": 0, "data": None}) raise AssertionError(path) client = JmsAuthClient( base_url="https://jms.example.test", transport=httpx.MockTransport(handler), ) await client.tenants() await client.precheck(tenant_code="tenant-code", username="operator") await client.captcha() await client.login( tenant_code="tenant-code", tenant_name="测试租户", username="operator", password="secret", captcha_id="cid", captcha_code="A7K9", ) await client.profile(token_name="X-JMS-Token", token_value="remote-token") await client.tenant(token_name="X-JMS-Token", token_value="remote-token") await client.logout(token_name="X-JMS-Token", token_value="remote-token") login_request = requests[3] assert dict(login_request.url.params) == {} assert login_request.headers["content-type"].startswith("application/x-www-form-urlencoded") assert dict(httpx.QueryParams(login_request.content.decode("utf-8"))) == { "tenantCode": "tenant-code", "tenantName": "测试租户", "username": "operator", "password": "secret", "device": "PC", "captchaId": "cid", "captchaCode": "A7K9", } assert all(request.headers["X-JMS-Token"] == "remote-token" for request in requests[4:]) @pytest.mark.asyncio async def test_provider_resolves_platform_by_display_name(jms_provider): provider, fake = jms_provider assert provider._select_tenant(tenant_name="平台") == { "code": "platform", "name": "平台", } await provider.login({ "method": "password", "tenantName": "平台", "username": "operator", "password": "secret", }) # Fake remote tenant still returns tenant-code; login by name alone uses alias code assert fake.login_payload["tenant_code"] == "platform" assert fake.login_payload["tenant_name"] == "平台" @pytest.mark.asyncio async def test_provider_login_by_tenant_name_without_public_list(jms_provider): provider, fake = jms_provider await provider.login({ "method": "password", "tenantName": "测试租户", "tenantCode": "tenant-code", "username": "operator", "password": "secret", }) assert fake.login_payload == { "tenant_code": "tenant-code", "tenant_name": "测试租户", "username": "operator", "password": "secret", "captcha_id": "", "captcha_code": "", } @pytest.mark.asyncio async def test_jms_http_client_rejects_remote_errors(): transport = httpx.MockTransport(lambda request: httpx.Response( 401, json={"code": 401, "message": "未提供登录凭证"}, )) client = JmsAuthClient(base_url="https://jms.example.test", transport=transport) with pytest.raises(JmsAuthClientError, match="未提供登录凭证") as exc: await client.profile(token_name="X-JMS-Token", token_value="bad") assert exc.value.status_code == 401 @pytest.mark.asyncio @pytest.mark.parametrize( ("envelope", "expected_code", "expected_message", "expected_status"), [ ( {"code": 500, "message": "系统异常,请稍后重试", "error": [ '400 BAD_REQUEST "Validation failure"', ]}, "AUTH_INVALID_CREDENTIALS", "账号或密码错误", 401, ), ( {"code": 400, "message": "图形验证码错误"}, "AUTH_CAPTCHA_INVALID", "验证码错误,请重新输入", 400, ), ( {"code": 500, "message": "系统异常,请稍后重试"}, "AUTH_UPSTREAM_ERROR", "认证服务暂时不可用,请稍后重试", 502, ), ], ) async def test_jms_http_client_translates_login_failures( envelope, expected_code, expected_message, expected_status, ): transport = httpx.MockTransport(lambda request: httpx.Response(500, json=envelope)) client = JmsAuthClient(base_url="https://jms.example.test", transport=transport) with pytest.raises(JmsAuthClientError, match=expected_message) as exc: await client.login( tenant_code="tenant-code", tenant_name="测试租户", username="operator", password="wrong-password", ) assert exc.value.code == expected_code assert exc.value.status_code == expected_status async def _no_role_profile(**kwargs: Any) -> dict[str, Any]: return { "id": "9007199254740002", "username": "operator2", "fullname": "计划员2", "state": "0", } @pytest.mark.asyncio async def test_profile_without_roles_defaults_to_planner(jms_provider, monkeypatch): """JMS profile 无 roles 字段(真实平台实测)→ 默认 planner,保证 P2 门禁可用。""" monkeypatch.delenv("JMS_AUTH_DEFAULT_ROLE", raising=False) provider, fake = jms_provider monkeypatch.setattr(fake, "profile", _no_role_profile) identity, _token = await provider.login({ "method": "password", "tenantCode": "tenant-code", "username": "operator", "password": "x", }) assert "planner" in identity.roles @pytest.mark.asyncio async def test_profile_without_roles_env_override(jms_provider, monkeypatch): monkeypatch.setenv("JMS_AUTH_DEFAULT_ROLE", "scheduler") provider, fake = jms_provider monkeypatch.setattr(fake, "profile", _no_role_profile) identity, _token = await provider.login({ "method": "password", "tenantCode": "tenant-code", "username": "operator", "password": "x", }) assert identity.roles == ("scheduler",) @pytest.mark.asyncio async def test_profile_with_roles_kept(jms_provider, monkeypatch): """JMS 明确返回角色时不得被默认值覆盖。""" monkeypatch.delenv("JMS_AUTH_DEFAULT_ROLE", raising=False) provider, fake = jms_provider identity, _token = await provider.login({ "method": "password", "tenantCode": "tenant-code", "username": "operator", "password": "x", }) assert set(identity.roles) == {"planner", "scheduler"}