Files
MoviePilot/tests/test_login_mfa_methods.py

175 lines
5.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from starlette.requests import Request
from starlette.responses import Response
from app import schemas
from app.api.endpoints import login as login_endpoint
from app.api.endpoints import mfa as mfa_endpoint
from app.chain.user import MfaRequired, UserChain
def _request() -> Request:
"""构造登录接口所需的最小请求。"""
return Request(
{
"type": "http",
"method": "POST",
"path": "/api/v1/login/access-token",
"headers": [(b"host", b"testserver")],
"scheme": "http",
"server": ("testserver", 80),
"client": ("testclient", 123),
}
)
def _form() -> SimpleNamespace:
"""构造密码登录表单契约。"""
return SimpleNamespace(username="user", password="password")
def test_verify_mfa_requires_otp_when_enabled():
"""密码通过后应返回账号已启用的 OTP 二次验证方式。"""
user = SimpleNamespace(id=1, name="user", is_otp=True, otp_secret="")
result = UserChain._verify_mfa(user=user, mfa_code=None)
assert isinstance(result, MfaRequired)
assert result.methods == ("otp",)
def test_verify_mfa_ignores_passkeys_when_otp_is_disabled():
"""Passkey 独立登录能力不应改变密码登录结果。"""
user = SimpleNamespace(id=1, name="user", is_otp=False, otp_secret="")
assert UserChain._verify_mfa(user=user, mfa_code=None) is True
def test_login_mfa_response_contains_methods_after_password_verification(monkeypatch):
"""MFA 响应应保持旧标记并补充结构化方法列表。"""
class FakeUserChain:
"""返回已通过密码校验的 MFA 要求。"""
def user_authenticate(self, username, password, mfa_code=None):
"""模拟账号启用了 OTP。"""
return False, MfaRequired(methods=("otp",))
monkeypatch.setattr(login_endpoint, "UserChain", FakeUserChain)
response = login_endpoint.login_access_token(
request=_request(),
response=Response(),
form_data=_form(),
)
assert response.status_code == 401
assert response.headers["x-mfa-required"] == "true"
assert json.loads(response.body) == {
"success": False,
"message": "需要二次验证",
"data": {"mfa_methods": ["otp"]},
}
def test_login_invalid_password_does_not_expose_mfa_methods(monkeypatch):
"""密码未通过时不得返回账号的 MFA 能力。"""
class FakeUserChain:
"""返回普通认证失败。"""
def user_authenticate(self, username, password, mfa_code=None):
"""模拟错误密码。"""
return False, "用户名、密码或验证码错误"
monkeypatch.setattr(login_endpoint, "UserChain", FakeUserChain)
with pytest.raises(HTTPException) as exc_info:
login_endpoint.login_access_token(
request=_request(),
response=Response(),
form_data=_form(),
)
assert exc_info.value.status_code == 401
assert exc_info.value.detail == "用户名或密码错误"
assert "X-MFA-Required" not in (exc_info.value.headers or {})
def test_wallpaper_returns_url_in_data(monkeypatch):
"""登录壁纸地址应放入 datamessage 只保留消息文本。"""
class FakeWallpaperHelper:
"""返回固定登录壁纸地址。"""
def get_wallpaper(self):
"""返回测试壁纸地址。"""
return "https://images.example/wallpaper.jpg"
monkeypatch.setattr(login_endpoint, "WallpaperHelper", FakeWallpaperHelper)
response = login_endpoint.wallpaper()
assert response.success is True
assert response.data == "https://images.example/wallpaper.jpg"
assert response.message == ""
assert not hasattr(response, "message_i18n")
def test_passkey_authentication_start_returns_object_options(monkeypatch):
"""Passkey 认证选项应作为对象返回,避免统一响应模型校验失败。"""
monkeypatch.setattr(
mfa_endpoint.PassKeyHelper,
"generate_authentication_options",
staticmethod(
lambda **_: ('{"challenge":"auth-challenge","timeout":60000}', "challenge")
),
)
monkeypatch.setattr(
mfa_endpoint.PasskeyChallengeStore,
"issue",
staticmethod(lambda **_: "authentication-transaction"),
)
response = mfa_endpoint.passkey_authenticate_start(
mfa_endpoint.PassKeyAuthenticationStart()
)
payload = schemas.PasskeyStartData.model_validate(response.data)
assert response.success is True
assert payload.options.root["challenge"] == "auth-challenge"
assert payload.transaction_token == "authentication-transaction"
def test_passkey_registration_start_returns_object_options(monkeypatch):
"""Passkey 注册选项应作为对象返回,避免统一响应模型校验失败。"""
monkeypatch.setattr(
mfa_endpoint.PassKey,
"get_by_user_id",
staticmethod(lambda **_: []),
)
monkeypatch.setattr(
mfa_endpoint.PassKeyHelper,
"generate_registration_options",
staticmethod(
lambda **_: ('{"challenge":"register-challenge"}', "challenge")
),
)
monkeypatch.setattr(
mfa_endpoint.PasskeyChallengeStore,
"issue",
staticmethod(lambda **_: "registration-transaction"),
)
user = SimpleNamespace(id=1, name="user", settings={})
response = mfa_endpoint.passkey_register_start(current_user=user)
payload = schemas.PasskeyStartData.model_validate(response.data)
assert response.success is True
assert payload.options.root["challenge"] == "register-challenge"
assert payload.transaction_token == "registration-transaction"