mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
370 lines
14 KiB
Python
370 lines
14 KiB
Python
"""
|
|
配置表、用户表与 PassKey 表的查询行为。
|
|
|
|
这几张表决定「谁能登录、看到什么配置」,查错一行的后果是越权或配置串用,而不是
|
|
一个能被日志发现的异常。同步方法都有一个已是 2.0 写法的异步孪生方法,这里对同一
|
|
批数据同时跑两条路径并要求结果一致——同步侧改写后若有偏差,这个断言会直接暴露。
|
|
"""
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
from app.db import base as db_base
|
|
from app.db.models.passkey import PassKey
|
|
from app.db.models.systemconfig import SystemConfig
|
|
from app.db.models.user import User
|
|
from app.db.models.userconfig import UserConfig
|
|
from app.db.oper.passkey import PassKeyOper
|
|
from app.db.oper.user import UserOper
|
|
from app.db.session import async_session_scope
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _track(db):
|
|
"""把本文件涉及的表纳入用例级回收。"""
|
|
db.watermark(SystemConfig, UserConfig, User, PassKey)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# SystemConfig
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def test_systemconfig_get_by_key_matches_async_twin(db):
|
|
"""
|
|
按键取配置的同步与异步结果必须一致,且只命中同名键。
|
|
"""
|
|
db.add(SystemConfig(key="mp-test-a", value={"n": 1}),
|
|
SystemConfig(key="mp-test-b", value={"n": 2}))
|
|
|
|
found = SystemConfig.get_by_key(db.session, "mp-test-a")
|
|
assert found.value == {"n": 1}
|
|
|
|
async_found = db.run_async_session(
|
|
lambda session: SystemConfig.async_get_by_key(session, "mp-test-a")
|
|
)
|
|
assert async_found.value == found.value
|
|
|
|
|
|
def test_systemconfig_get_by_key_returns_none_when_absent(db):
|
|
"""
|
|
键不存在时返回 None——调用方据此决定是否落默认值。
|
|
"""
|
|
assert SystemConfig.get_by_key(db.session, "mp-test-missing") is None
|
|
|
|
|
|
def test_systemconfig_queries_reuse_explicit_sessions(db, monkeypatch):
|
|
"""SystemConfig 显式同步与异步会话不得触发兼容会话。"""
|
|
db.add(SystemConfig(key="mp-explicit-config", value=True))
|
|
monkeypatch.setattr(
|
|
db_base,
|
|
"run_sync_transaction",
|
|
lambda _operation: (_ for _ in ()).throw(
|
|
AssertionError("不应创建额外同步事务")
|
|
),
|
|
)
|
|
assert SystemConfig.get_by_key(db.session, "mp-explicit-config") is not None
|
|
|
|
async def check() -> None:
|
|
"""验证异步配置查询复用显式 AsyncSession。"""
|
|
async with async_session_scope() as session:
|
|
monkeypatch.setattr(
|
|
db_base,
|
|
"run_async_transaction",
|
|
lambda _operation: (_ for _ in ()).throw(
|
|
AssertionError("不应创建额外异步事务")
|
|
),
|
|
)
|
|
assert await SystemConfig.async_get_by_key(
|
|
session,
|
|
"mp-explicit-config",
|
|
) is not None
|
|
|
|
asyncio.run(check())
|
|
|
|
|
|
def test_systemconfig_delete_by_key_removes_only_that_key(db):
|
|
"""
|
|
按键删除只能删掉那一个键,误删会静默丢失其他配置。
|
|
"""
|
|
db.add(SystemConfig(key="mp-test-del", value={"n": 1}),
|
|
SystemConfig(key="mp-test-keep", value={"n": 2}))
|
|
|
|
assert SystemConfig().delete_by_key(db.session, "mp-test-del") is True
|
|
|
|
assert SystemConfig.get_by_key(db.session, "mp-test-del") is None
|
|
assert SystemConfig.get_by_key(db.session, "mp-test-keep").value == {"n": 2}
|
|
|
|
|
|
def test_systemconfig_delete_by_key_tolerates_missing_key(db):
|
|
"""
|
|
删除不存在的键返回 True 而不抛异常,保持调用方的幂等语义。
|
|
"""
|
|
assert SystemConfig().delete_by_key(db.session, "mp-test-missing") is True
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# UserConfig
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def test_userconfig_get_by_key_scopes_by_username(db):
|
|
"""
|
|
用户配置必须同时按用户名和键命中——只按键会把别人的配置读给当前用户。
|
|
"""
|
|
db.add(UserConfig(username="alice", key="theme", value="dark"),
|
|
UserConfig(username="bob", key="theme", value="light"))
|
|
|
|
assert UserConfig.get_by_key(db.session, username="alice", key="theme").value == "dark"
|
|
assert UserConfig.get_by_key(db.session, username="bob", key="theme").value == "light"
|
|
assert UserConfig.get_by_key(db.session, username="carol", key="theme") is None
|
|
|
|
|
|
def test_userconfig_delete_by_key_removes_only_that_user(db):
|
|
"""
|
|
删除某用户的配置不能波及同名键的其他用户。
|
|
"""
|
|
db.add(UserConfig(username="alice", key="theme", value="dark"),
|
|
UserConfig(username="bob", key="theme", value="light"))
|
|
|
|
assert UserConfig().delete_by_key(db.session, username="alice", key="theme") is True
|
|
|
|
assert UserConfig.get_by_key(db.session, username="alice", key="theme") is None
|
|
assert UserConfig.get_by_key(db.session, username="bob", key="theme").value == "light"
|
|
|
|
|
|
def test_userconfig_delete_by_key_tolerates_missing_row(db):
|
|
"""
|
|
删除不存在的用户配置返回 True,不抛异常。
|
|
"""
|
|
assert UserConfig().delete_by_key(db.session, username="nobody", key="theme") is True
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# User
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def test_user_lookup_by_name_and_id_matches_async_twin(db):
|
|
"""
|
|
按名与按 ID 取用户的同步、异步结果必须指向同一行。
|
|
|
|
登录链路走同步、API 依赖注入走异步,两者不一致会表现为「能登录但查不到自己」。
|
|
"""
|
|
created = db.add(User(name="mp-test-user", email="u@example.com",
|
|
hashed_password="x", is_active=True))
|
|
|
|
by_name = User.get_by_name(db.session, "mp-test-user")
|
|
by_id = User.get_by_id(db.session, created.id)
|
|
assert by_name.id == by_id.id == created.id
|
|
|
|
assert db.run_async_session(
|
|
lambda session: User.async_get_by_name(session, "mp-test-user")
|
|
).id == created.id
|
|
assert db.run_async_session(
|
|
lambda session: User.async_get_by_id(session, created.id)
|
|
).id == created.id
|
|
|
|
|
|
def test_user_lookup_returns_none_when_absent(db):
|
|
"""
|
|
查无此人时返回 None,而不是抛异常或返回任意一行。
|
|
"""
|
|
assert User.get_by_name(db.session, "mp-test-nobody") is None
|
|
assert User.get_by_id(db.session, -1) is None
|
|
|
|
|
|
def test_user_delete_by_name_and_by_id_remove_only_the_target(db):
|
|
"""
|
|
按名、按 ID 删除都只能删掉目标用户。
|
|
"""
|
|
keep = db.add(User(name="mp-test-keep", hashed_password="x"))
|
|
drop_by_name = db.add(User(name="mp-test-drop-name", hashed_password="x"))
|
|
drop_by_id = db.add(User(name="mp-test-drop-id", hashed_password="x"))
|
|
|
|
assert User().delete_by_name(db.session, drop_by_name.name) is True
|
|
assert User().delete_by_id(db.session, drop_by_id.id) is True
|
|
|
|
assert User.get_by_name(db.session, "mp-test-drop-name") is None
|
|
assert User.get_by_id(db.session, drop_by_id.id) is None
|
|
assert User.get_by_id(db.session, keep.id) is not None
|
|
|
|
|
|
def test_user_update_otp_reports_whether_user_existed(db):
|
|
"""
|
|
更新 OTP 必须如实反馈用户是否存在。
|
|
|
|
对不存在的用户返回 True 会让上层以为二次验证已开启,实际并没有。
|
|
"""
|
|
db.add(User(name="mp-test-otp", hashed_password="x", is_otp=False))
|
|
|
|
assert User().update_otp_by_name(db.session, "mp-test-otp", True, "SECRET") is True
|
|
assert User().update_otp_by_name(db.session, "mp-test-nobody", True, "SECRET") is False
|
|
|
|
updated = User.get_by_name(db.session, "mp-test-otp")
|
|
assert (updated.is_otp, updated.otp_secret) == (True, "SECRET")
|
|
|
|
|
|
def test_user_async_mutations_match_sync_behaviour(db):
|
|
"""
|
|
异步的删除与 OTP 更新必须与同步路径给出相同的存在性判断。
|
|
"""
|
|
async_id_user = db.add(User(name="mp-test-async-id", hashed_password="x"))
|
|
db.add(User(name="mp-test-async-otp", hashed_password="x", is_otp=False))
|
|
oper = UserOper()
|
|
|
|
assert asyncio.run(oper.async_update_otp_by_name(
|
|
name="mp-test-async-otp", otp=True, secret="S2")) is True
|
|
assert asyncio.run(oper.async_update_otp_by_name(
|
|
name="mp-test-nobody", otp=True, secret="S2")) is False
|
|
|
|
assert asyncio.run(oper.async_delete_by_name(name="mp-test-async-otp")) is True
|
|
assert User.get_by_name(db.session, "mp-test-async-otp") is None
|
|
|
|
asyncio.run(oper.async_delete(async_id_user.id))
|
|
assert User.get_by_id(db.session, async_id_user.id) is None
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# PassKey
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def _passkey(user_id: int, credential_id: str, is_active: bool = True) -> PassKey:
|
|
"""构造一条 PassKey 记录。"""
|
|
return PassKey(user_id=user_id, credential_id=credential_id,
|
|
public_key="pk", sign_count=0, is_active=is_active)
|
|
|
|
|
|
def test_passkey_listing_excludes_inactive_credentials(db):
|
|
"""
|
|
列出用户凭据时必须排除已停用的。
|
|
|
|
停用的凭据仍能被列出意味着它还会出现在登录选项里,等于停用没生效。
|
|
"""
|
|
db.add(_passkey(9001, "cred-active-1"),
|
|
_passkey(9001, "cred-active-2"),
|
|
_passkey(9001, "cred-inactive", is_active=False),
|
|
_passkey(9002, "cred-other"))
|
|
|
|
listed = PassKey.get_by_user_id(db.session, 9001)
|
|
|
|
assert {p.credential_id for p in listed} == {"cred-active-1", "cred-active-2"}
|
|
assert {p.credential_id for p in db.run_async_session(
|
|
lambda session: PassKey.async_get_by_user_id(session, 9001)
|
|
)} == \
|
|
{"cred-active-1", "cred-active-2"}
|
|
|
|
|
|
def test_passkey_oper_queries_use_explicit_session(db, monkeypatch):
|
|
"""PassKeyOper 的宿主查询使用调用方 Session,不创建兼容事务。"""
|
|
db.add(_passkey(9002, "cred-oper"), _passkey(9002, "cred-oper-inactive", is_active=False))
|
|
monkeypatch.setattr(
|
|
"app.db.base.run_sync_transaction",
|
|
lambda _query: pytest.fail("显式 Session 查询不应创建兼容事务"),
|
|
)
|
|
|
|
oper = PassKeyOper(db.session)
|
|
|
|
assert {item.credential_id for item in oper.list()} == {
|
|
"cred-oper",
|
|
"cred-oper-inactive",
|
|
}
|
|
assert [item.credential_id for item in oper.list_by_user_id(9002)] == ["cred-oper"]
|
|
assert oper.get_by_credential_id("cred-oper").user_id == 9002
|
|
assert oper.get_by_credential_id("cred-oper-inactive") is None
|
|
|
|
|
|
def test_passkey_lookup_by_credential_id_skips_inactive(db):
|
|
"""
|
|
按凭据 ID 查找同样必须忽略停用记录,否则停用的密钥仍可完成认证。
|
|
"""
|
|
db.add(_passkey(9003, "cred-live"), _passkey(9003, "cred-dead", is_active=False))
|
|
|
|
assert PassKey.get_by_credential_id(db.session, "cred-live").user_id == 9003
|
|
assert PassKey.get_by_credential_id(db.session, "cred-dead") is None
|
|
assert db.run_async_session(
|
|
lambda session: PassKey.async_get_by_credential_id(session, "cred-dead")
|
|
) is None
|
|
|
|
|
|
def test_passkey_remaining_queries_reuse_explicit_sessions(db, monkeypatch):
|
|
"""PassKey 其余同步/异步查询必须复用调用方会话。"""
|
|
key = db.add(_passkey(9008, "cred-explicit"))
|
|
monkeypatch.setattr(
|
|
db_base,
|
|
"run_sync_transaction",
|
|
lambda _operation: (_ for _ in ()).throw(
|
|
AssertionError("不应创建额外同步事务")
|
|
),
|
|
)
|
|
assert PassKey.get_by_id(db.session, key.id).credential_id == "cred-explicit"
|
|
|
|
async def check() -> None:
|
|
"""验证三个异步查询都复用显式 AsyncSession。"""
|
|
async with async_session_scope() as session:
|
|
monkeypatch.setattr(
|
|
db_base,
|
|
"run_async_transaction",
|
|
lambda _operation: (_ for _ in ()).throw(
|
|
AssertionError("不应创建额外异步事务")
|
|
),
|
|
)
|
|
assert [item.credential_id for item in await PassKey.async_get_by_user_id(
|
|
session,
|
|
9008,
|
|
)] == ["cred-explicit"]
|
|
assert await PassKey.async_get_by_credential_id(
|
|
session,
|
|
"cred-explicit",
|
|
) is not None
|
|
assert await PassKey.async_get_by_id(session, key.id) is not None
|
|
|
|
asyncio.run(check())
|
|
|
|
|
|
def test_passkey_get_by_id_ignores_active_flag(db):
|
|
"""
|
|
按主键取记录是管理用途,不应过滤停用状态——否则管理端看不到自己刚停用的凭据。
|
|
"""
|
|
dead = db.add(_passkey(9004, "cred-admin", is_active=False))
|
|
|
|
assert PassKey.get_by_id(db.session, dead.id).credential_id == "cred-admin"
|
|
assert db.run_async_session(
|
|
lambda session: PassKey.async_get_by_id(session, dead.id)
|
|
).credential_id == "cred-admin"
|
|
|
|
|
|
def test_passkey_delete_requires_matching_owner(db):
|
|
"""
|
|
删除必须同时匹配主键与所属用户。
|
|
|
|
只按主键删除即为越权:任意登录用户都能删掉别人的凭据。
|
|
"""
|
|
victim = db.add(_passkey(9005, "cred-victim"))
|
|
|
|
assert PassKey.delete_by_id(db.session, passkey_id=victim.id, user_id=9999) is False
|
|
assert PassKey.get_by_id(db.session, victim.id) is not None
|
|
|
|
assert PassKey.delete_by_id(db.session, passkey_id=victim.id, user_id=9005) is True
|
|
assert PassKey.get_by_id(db.session, victim.id) is None
|
|
|
|
|
|
def test_passkey_async_delete_enforces_the_same_ownership_rule(db):
|
|
"""
|
|
异步删除必须与同步路径使用同一套归属判定。
|
|
"""
|
|
victim = db.add(_passkey(9006, "cred-async-victim"))
|
|
|
|
oper = PassKeyOper()
|
|
assert asyncio.run(oper.async_delete_by_id(passkey_id=victim.id, user_id=9999)) is False
|
|
assert asyncio.run(oper.async_delete_by_id(passkey_id=victim.id, user_id=9006)) is True
|
|
assert PassKey.get_by_id(db.session, victim.id) is None
|
|
|
|
|
|
def test_passkey_update_last_used_persists_sign_count(db):
|
|
"""
|
|
签名计数必须落库——它是防重放的依据,不落库等于校验形同虚设。
|
|
"""
|
|
key = db.add(_passkey(9007, "cred-count"))
|
|
|
|
assert key.update_last_used(db.session, sign_count=42) is True
|
|
|
|
assert PassKey.get_by_id(db.session, key.id).sign_count == 42
|