mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-14 10:14:36 +08:00
175 lines
5.7 KiB
Python
175 lines
5.7 KiB
Python
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):
|
||
"""登录壁纸地址应放入 data,message 只保留消息文本。"""
|
||
|
||
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"
|