mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-15 17:30:13 +08:00
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:
@@ -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"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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
85
tests/test_auth.py
Normal 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
62
tests/test_imports.py
Normal 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
70
tests/test_startup.py
Normal 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)
|
||||
Reference in New Issue
Block a user