test: add import smoke, auth roundtrip, and startup tests

- test_imports: catch missing symbols and abstract methods
- test_auth: argon2id roundtrip, password validation, login challenge
- test_startup: verify core modules import without crashing
fix: restore BotMessageAccumulator and helpers lost in cherry-pick
This commit is contained in:
LIghtJUNction
2026-04-29 03:39:27 +08:00
parent 1d36f04e1c
commit 0c47c27e4b
5 changed files with 238 additions and 19 deletions

View File

@@ -72,7 +72,6 @@ from astrbot.core.tools.knowledge_base_tools import (
KnowledgeBaseQueryTool,
retrieve_knowledge_base,
)
from astrbot.core.tools.message_tools import SendMessageToUserTool
from astrbot.core.tools.web_search_tools import (
BaiduWebSearchTool,
BochaWebSearchTool,
@@ -280,7 +279,7 @@ async def _apply_file_extract(
logger.error("Unsupported file extract provider: %s", config.file_extract_prov)
return
for file_content, file_name in zip(file_contents, file_names):
for file_content, file_name in zip(file_contents, file_names, strict=False):
req.contexts.append(
{
"role": "system",
@@ -627,7 +626,7 @@ def _get_quoted_message_parser_settings(
overrides = provider_settings.get("quoted_message_parser")
if not isinstance(overrides, dict):
return DEFAULT_QUOTED_MESSAGE_SETTINGS
return DEFAULT_QUOTED_MESSAGE_SETTINGS.with_overrides(overrides)
return DEFAULT_QUOTED_MESSAGE_SETTINGS.with_overrides(overrides) # type: ignore[arg-type]
def _get_image_compress_args(
@@ -640,8 +639,11 @@ def _get_image_compress_args(
if not isinstance(enabled, bool):
enabled = True
raw_options = provider_settings.get("image_compress_options", {})
options = raw_options if isinstance(raw_options, dict) else {}
raw_options = provider_settings.get("image_compress_options")
if isinstance(raw_options, dict):
options = dict(raw_options.items())
else:
options = {}
max_size = options.get("max_size", IMAGE_COMPRESS_DEFAULT_MAX_SIZE)
if not isinstance(max_size, int):
@@ -752,7 +754,7 @@ async def _process_quote_message(
compress_path
and compress_path != path
and os.path.exists(compress_path)
):
): # noqa: PNT120
try:
os.remove(compress_path)
except Exception as exc: # noqa: BLE001
@@ -863,7 +865,7 @@ def _plugin_tool_fix(event: AstrMessageEvent, req: ProviderRequest) -> None:
# 保留 MCP 工具
new_tool_set.add_tool(tool)
continue
mp = tool.handler_module_path
mp = getattr(tool, "handler_module_path", None)
if not mp:
# 没有 plugin 归属信息的工具(如 subagent transfer_to_*
# 不应受到会话插件过滤影响。
@@ -1330,7 +1332,7 @@ async def build_main_agent(
req.func_tool = ToolSet()
req.func_tool.add_tool(
plugin_context.get_llm_tool_manager().get_builtin_tool(
SendMessageToUserTool
"send_message_to_user"
)
)

View File

@@ -2102,7 +2102,7 @@ class SQLiteDatabase(BaseDatabase):
) -> tuple[list[PlatformMessageHistory], int | None]:
async with self.get_db() as session:
session: AsyncSession
query = (
query = ( # type: ignore
select(PlatformMessageHistory)
.where(
PlatformMessageHistory.platform_id == platform_id,
@@ -2111,7 +2111,7 @@ class SQLiteDatabase(BaseDatabase):
.order_by(desc(PlatformMessageHistory.created_at))
)
if cursor_id is not None:
query = query.where(PlatformMessageHistory.id < cursor_id)
query = query.where(PlatformMessageHistory.id < cursor_id) # type: ignore
result = await session.execute(query.limit(limit))
records = list(result.scalars().all())
total = None
@@ -2119,7 +2119,7 @@ class SQLiteDatabase(BaseDatabase):
count_result = await session.execute(
select(func.count())
.select_from(PlatformMessageHistory)
.where(
.where( # type: ignore
PlatformMessageHistory.platform_id == platform_id,
PlatformMessageHistory.user_id == user_id,
)
@@ -2137,13 +2137,13 @@ class SQLiteDatabase(BaseDatabase):
session: AsyncSession
async with session.begin():
result = await session.execute(
delete(PlatformMessageHistory).where(
delete(PlatformMessageHistory).where( # type: ignore
PlatformMessageHistory.platform_id == platform_id,
PlatformMessageHistory.user_id == user_id,
PlatformMessageHistory.created_at < before,
)
)
return result.rowcount
return result.rowcount # type: ignore
async def delete_platform_message_after(
self,
@@ -2155,13 +2155,13 @@ class SQLiteDatabase(BaseDatabase):
session: AsyncSession
async with session.begin():
result = await session.execute(
delete(PlatformMessageHistory).where(
delete(PlatformMessageHistory).where( # type: ignore
PlatformMessageHistory.platform_id == platform_id,
PlatformMessageHistory.user_id == user_id,
PlatformMessageHistory.created_at > after,
)
)
return result.rowcount
return result.rowcount # type: ignore
async def delete_all_platform_message_history(
self,
@@ -2172,12 +2172,12 @@ class SQLiteDatabase(BaseDatabase):
session: AsyncSession
async with session.begin():
result = await session.execute(
delete(PlatformMessageHistory).where(
delete(PlatformMessageHistory).where( # type: ignore
PlatformMessageHistory.platform_id == platform_id,
PlatformMessageHistory.user_id == user_id,
)
)
return result.rowcount
return result.rowcount # type: ignore
async def find_platform_message_history_by_idempotency_key(
self,
@@ -2188,10 +2188,10 @@ class SQLiteDatabase(BaseDatabase):
async with self.get_db() as session:
session: AsyncSession
result = await session.execute(
select(PlatformMessageHistory).where(
select(PlatformMessageHistory).where( # type: ignore
PlatformMessageHistory.platform_id == platform_id,
PlatformMessageHistory.user_id == user_id,
PlatformMessageHistory.idempotency_key == idempotency_key,
PlatformMessageHistory.idempotency_key == idempotency_key, # type: ignore
)
)
return result.scalar_one_or_none()

85
tests/test_auth.py Normal file
View File

@@ -0,0 +1,85 @@
"""Ensure frontend login logic and backend argon2id verification match.
This guards against the kind of password-hash mismatch that can be
introduced when the frontend encryption/encoding logic and the backend
verification code drift apart.
"""
from astrbot.core.utils.auth_password import (
hash_dashboard_password,
validate_dashboard_password,
verify_dashboard_password,
is_default_dashboard_password,
is_legacy_dashboard_password,
get_dashboard_login_challenge,
verify_dashboard_login_proof,
)
def test_password_hash_roundtrip():
"""Backend argon2id hash → verify round-trip."""
password = "test-password-123"
hashed = hash_dashboard_password(password)
assert hashed.startswith("$argon2id$"), f"Expected argon2id hash, got: {hashed}"
assert verify_dashboard_password(hashed, password)
assert not verify_dashboard_password(hashed, "wrong-password")
def test_validate_password_strength():
"""validate_dashboard_password should reject weak passwords."""
validate_dashboard_password("StrongPass1!") # should not raise
import pytest
with pytest.raises(ValueError):
validate_dashboard_password("short")
with pytest.raises(ValueError):
validate_dashboard_password("")
def test_is_default_password():
"""is_default / is_legacy detection helpers."""
assert is_default_dashboard_password(hash_dashboard_password("astrbot"))
# Legacy MD5-style hash detection
assert is_legacy_dashboard_password("e99a18c428cb38d5f260853678922e03")
def test_login_challenge_argon2():
"""get_dashboard_login_challenge for argon2 returns algorithm marker."""
password = "test-challenge-pw"
hashed = hash_dashboard_password(password)
challenge = get_dashboard_login_challenge(hashed)
assert challenge == {"algorithm": "argon2"}
def test_login_challenge_pbkdf2():
"""get_dashboard_login_challenge + verify_dashboard_login_proof for PBKDF2."""
import hashlib, hmac
salt = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
password = "test-pw"
iterations = 600000
algo = "pbkdf2_sha256"
# Build a PBKDF2 hash matching auth_password format: algo$iterations$salt$derived_key
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt), iterations)
stored = f"{algo}${iterations}${salt}${dk.hex()}"
challenge = get_dashboard_login_challenge(stored)
assert challenge["algorithm"] == algo
assert challenge["salt"] == salt
assert challenge["iterations"] == iterations
# The proof is HMAC-SHA256(derived_key, nonce) — the derived key IS the digest
proof = hmac.new(
dk,
"any-nonce".encode(),
hashlib.sha256,
).hexdigest()
assert verify_dashboard_login_proof(stored, "any-nonce", proof)
assert not verify_dashboard_login_proof(
stored, "any-nonce", "invalid-proof"
)

62
tests/test_imports.py Normal file
View File

@@ -0,0 +1,62 @@
"""Lightweight import smoke test — runs in CI and PKGBUILD check().
Catches missing symbols, undefined names, and abstract methods that are not
implemented — the kind of breakage that ``git merge -X theirs`` or a botched
cherry-pick silently introduces.
"""
# ---------------------------------------------------------------------------
# Layer 1 — symbol-level imports (曾炸过的 import 路径)
# ---------------------------------------------------------------------------
from astrbot.core.astr_main_agent_resources import (
BACKGROUND_TASK_RESULT_WOKE_SYSTEM_PROMPT,
BACKGROUND_TASK_WOKE_USER_PROMPT,
CONVERSATION_HISTORY_INJECT_PREFIX,
)
from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor
from astrbot.core.pipeline.process_stage.stage import ProcessStage # noqa: F401
# Dashboard routes (曾炸过 sentinel class / import 丢失)
from astrbot.dashboard.routes.live_chat import LiveChatRoute # noqa: F401
from astrbot.dashboard.routes.chat import ChatRoute # noqa: F401
from astrbot.dashboard.server import AstrBotDashboard # noqa: F401
# Pipeline scheduler
from astrbot.core.pipeline.scheduler import PipelineScheduler # noqa: F401
# Auth password utilities
from astrbot.core.utils.auth_password import (
hash_dashboard_password,
validate_dashboard_password,
verify_dashboard_password,
is_default_dashboard_password,
) # noqa: F401
# ---------------------------------------------------------------------------
# Layer 2 — abstract-method completeness
# ---------------------------------------------------------------------------
def test_platform_abstract_methods():
"""Platform (ABC) should not crash on import and its abstract methods
should be introspectable.
Concrete subclasses that can be instantiated without network/external
dependencies should also be verified here.
"""
from astrbot.core.platform.platform import Platform
import inspect
# Introspect abstract methods — this caught SQLiteDatabase missing 5 methods
abstract_methods = set(
meth
for meth in Platform.__abstractmethods__ # type: ignore[attr-defined]
)
assert isinstance(abstract_methods, set)
assert len(abstract_methods) > 0
def test_live_chat_route():
"""LiveChatRoute can be imported without errors."""
from astrbot.dashboard.routes.live_chat import LiveChatRoute
assert LiveChatRoute is not None

70
tests/test_startup.py Normal file
View File

@@ -0,0 +1,70 @@
"""Verify core modules can be imported and initialised without crashing.
This catches silent breakage like missing sentinel classes, reordered
variable definitions, or constructor signature changes that only blow up
at runtime.
"""
def test_core_lifecycle_import():
"""CoreLifecycle class definition is importable."""
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
assert AstrBotCoreLifecycle is not None
def test_dashboard_server_import():
"""AstrBotDashboard class definition is importable."""
from astrbot.dashboard.server import AstrBotDashboard
assert AstrBotDashboard is not None
def test_pipeline_scheduler_import():
"""PipelineScheduler class definition is importable."""
from astrbot.core.pipeline.scheduler import PipelineScheduler
assert PipelineScheduler is not None
def test_process_stage_import():
"""ProcessStage class definition is importable."""
from astrbot.core.pipeline.process_stage.stage import ProcessStage
assert ProcessStage is not None
def test_platform_base_import():
"""Platform ABC is importable and has abstract methods."""
from astrbot.core.platform.platform import Platform
assert len(Platform.__abstractmethods__) > 0 # type: ignore[attr-defined]
def test_auth_route_import():
"""AuthRoute class definition is importable."""
from astrbot.dashboard.routes.auth import AuthRoute
assert AuthRoute is not None
def test_log_route_import():
"""LogRoute (live-log SSE) class definition is importable."""
from astrbot.dashboard.routes.log import LogRoute
assert LogRoute is not None
def test_password_utils_import():
"""All password utility functions are importable."""
from astrbot.core.utils.auth_password import (
hash_dashboard_password,
verify_dashboard_password,
verify_dashboard_login_proof,
get_dashboard_login_challenge,
is_default_dashboard_password,
is_legacy_dashboard_password,
)
assert callable(hash_dashboard_password)
assert callable(verify_dashboard_password)