Files
MoviePilot/tests/test_db_config_user_queries.py

264 lines
10 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.
"""
配置表、用户表与 PassKey 表的查询行为。
这几张表决定「谁能登录、看到什么配置」,查错一行的后果是越权或配置串用,而不是
一个能被日志发现的异常。同步方法都有一个已是 2.0 写法的异步孪生方法,这里对同一
批数据同时跑两条路径并要求结果一致——同步侧改写后若有偏差,这个断言会直接暴露。
"""
import asyncio
import pytest
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
@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 = asyncio.run(SystemConfig.async_get_by_key(key="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_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 asyncio.run(User.async_get_by_name(name="mp-test-user")).id == created.id
assert asyncio.run(User.async_get_by_id(user_id=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 更新必须与同步路径给出相同的存在性判断。
"""
db.add(User(name="mp-test-async-otp", hashed_password="x", is_otp=False))
assert asyncio.run(User().async_update_otp_by_name(
name="mp-test-async-otp", otp=True, secret="S2")) is True
assert asyncio.run(User().async_update_otp_by_name(
name="mp-test-nobody", otp=True, secret="S2")) is False
assert asyncio.run(User().async_delete_by_name(name="mp-test-async-otp")) is True
assert User.get_by_name(db.session, "mp-test-async-otp") 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 asyncio.run(PassKey.async_get_by_user_id(user_id=9001))} == \
{"cred-active-1", "cred-active-2"}
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 asyncio.run(PassKey.async_get_by_credential_id(credential_id="cred-dead")) is None
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 asyncio.run(PassKey.async_get_by_id(passkey_id=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"))
assert asyncio.run(PassKey.async_delete_by_id(passkey_id=victim.id, user_id=9999)) is False
assert asyncio.run(PassKey.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