""" 验证通行密钥注册错误的业务归一与配置修正提示。 """ from types import SimpleNamespace from unittest.mock import patch import pytest import webauthn import webauthn.helpers from webauthn.helpers.exceptions import InvalidRegistrationResponse from app.api.endpoints import mfa as mfa_endpoint from app.application.security.passkey import ( PASSKEY_CHALLENGE_TTL_SECONDS, PasskeyChallengeStore, PassKeyHelper, PassKeyRegistrationOriginMismatchError, PassKeyRegistrationVerificationError, configure_passkey_challenge_cache, ) from app.runtime.cache import TTLCache def setup_function(): """ 为注册错误路径显式装配隔离的 challenge 缓存。 """ configure_passkey_challenge_cache(TTLCache( region="passkey_challenge", maxsize=4096, ttl=PASSKEY_CHALLENGE_TTL_SECONDS, )) def _registration_request(user_id: int = 1) -> mfa_endpoint.PassKeyRegistrationFinish: """ 构造只用于错误路径的注册完成请求。 """ transaction_token = PasskeyChallengeStore.issue( challenge="challenge", purpose="registration", user_id=user_id, ) return mfa_endpoint.PassKeyRegistrationFinish( credential={"id": "credential-id"}, transaction_token=transaction_token, name="测试通行密钥", ) def _current_user() -> SimpleNamespace: """ 构造注册错误路径所需的当前用户契约。 """ return SimpleNamespace(id=1, name="admin") def test_passkey_helper_classifies_origin_mismatch(): """ 来源不一致应在 WebAuthn 边界转换为稳定的业务异常。 """ library_error = InvalidRegistrationResponse( 'Unexpected client data origin "http://localhost:5173", ' 'expected "http://localhost:3000"' ) # passkey 在方法内按需导入 webauthn,替身打在 webauthn 的来源模块上 with patch.object( webauthn.helpers, "parse_registration_credential_json", return_value=object(), ), patch.object( webauthn, "verify_registration_response", side_effect=library_error, ), pytest.raises(PassKeyRegistrationOriginMismatchError) as exc_info: PassKeyHelper.verify_registration_response( credential={"id": "credential-id"}, expected_challenge="Y2hhbGxlbmdl", ) assert exc_info.value.__cause__ is library_error def test_passkey_helper_classifies_other_verification_failure(): """ 其他注册验证错误不应被误判为来源配置问题。 """ library_error = InvalidRegistrationResponse( "Client data challenge was not expected challenge" ) # passkey 在方法内按需导入 webauthn,替身打在 webauthn 的来源模块上 with patch.object( webauthn.helpers, "parse_registration_credential_json", return_value=object(), ), patch.object( webauthn, "verify_registration_response", side_effect=library_error, ), pytest.raises(PassKeyRegistrationVerificationError) as exc_info: PassKeyHelper.verify_registration_response( credential={"id": "credential-id"}, expected_challenge="Y2hhbGxlbmdl", ) assert exc_info.value.__cause__ is library_error def test_passkey_register_finish_returns_actionable_origin_message(): """ 来源配置不一致时应告诉管理员如何修正访问地址。 """ with patch.object( mfa_endpoint.PassKeyHelper, "verify_registration_response", side_effect=PassKeyRegistrationOriginMismatchError(), ): response = mfa_endpoint.passkey_register_finish( passkey_req=_registration_request(), current_user=_current_user(), ) assert not response.success assert response.message == "访问域名与系统配置不一致,请使用配置的域名重试" assert "APP_DOMAIN" not in response.message assert "Unexpected client data origin" not in response.message def test_passkey_register_finish_hides_other_verification_details(): """ 其他 WebAuthn 验证细节只记录在服务端,不返回给客户端。 """ with patch.object( mfa_endpoint.PassKeyHelper, "verify_registration_response", side_effect=PassKeyRegistrationVerificationError( "Client data challenge was not expected challenge" ), ): response = mfa_endpoint.passkey_register_finish( passkey_req=_registration_request(), current_user=_current_user(), ) assert not response.success assert response.message == "通行密钥注册验证失败,请重新发起注册后重试" assert "challenge" not in response.message def test_passkey_register_finish_hides_unexpected_error_details(): """ 未知内部异常应返回通用提示,避免泄露实现信息。 """ with patch.object( mfa_endpoint.PassKeyHelper, "verify_registration_response", side_effect=RuntimeError("database connection details"), ): response = mfa_endpoint.passkey_register_finish( passkey_req=_registration_request(), current_user=_current_user(), ) assert not response.success assert response.message == "通行密钥注册失败,请稍后重试" assert "database" not in response.message