fix: restrict sensitive system endpoints

This commit is contained in:
jxxghp
2026-06-09 21:45:51 +08:00
parent d1cf584af9
commit dc2b6910a4
10 changed files with 476 additions and 78 deletions

View File

@@ -7,9 +7,10 @@ from sqlalchemy.orm import Session
from app import schemas
from app.chain.dashboard import DashboardChain
from app.chain.storage import StorageChain
from app.core.security import verify_token, verify_apitoken
from app.core.security import verify_apitoken
from app.db import get_db
from app.db.models.transferhistory import TransferHistory
from app.db.user_oper import get_current_active_superuser
from app.helper.directory import DirectoryHelper
from app.scheduler import Scheduler
from app.utils.system import SystemUtils
@@ -17,12 +18,9 @@ from app.utils.system import SystemUtils
router = APIRouter()
@router.get("/statistic", summary="媒体数量统计", response_model=schemas.Statistic)
def statistic(
name: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)
) -> Any:
def _build_statistic(name: Optional[str] = None) -> schemas.Statistic:
"""
查询媒体数量统计信息
构建媒体数量统计信息
"""
media_statistics: Optional[List[schemas.Statistic]] = (
DashboardChain().media_statistic(name)
@@ -42,24 +40,12 @@ def statistic(
# 所有媒体服务都未提供剧集统计时,返回 None 供前端展示“未获取”。
ret_statistic.episode_count = None
return ret_statistic
else:
return schemas.Statistic()
return schemas.Statistic()
@router.get(
"/statistic2", summary="媒体数量统计API_TOKEN", response_model=schemas.Statistic
)
def statistic2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
def _build_storage() -> schemas.Storage:
"""
查询媒体数量统计信息 API_TOKEN认证?token=xxx
"""
return statistic()
@router.get("/storage", summary="本地存储空间", response_model=schemas.Storage)
def storage(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询本地存储空间信息
构建本地存储空间信息。
"""
total, available = 0, 0
dirs = DirectoryHelper().get_dirs()
@@ -74,30 +60,9 @@ def storage(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
return schemas.Storage(total_storage=total, used_storage=total - available)
@router.get(
"/storage2", summary="本地存储空间API_TOKEN", response_model=schemas.Storage
)
def storage2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
def _build_downloader(name: Optional[str] = None) -> schemas.DownloaderInfo:
"""
查询本地存储空间信息 API_TOKEN认证?token=xxx
"""
return storage()
@router.get("/processes", summary="进程信息", response_model=List[schemas.ProcessInfo])
def processes(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询进程信息
"""
return SystemUtils.processes()
@router.get("/downloader", summary="下载器信息", response_model=schemas.DownloaderInfo)
def downloader(
name: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)
) -> Any:
"""
查询下载器信息
构建下载器统计信息。
"""
# 下载目录空间
download_dirs = DirectoryHelper().get_local_download_dirs()
@@ -117,6 +82,62 @@ def downloader(
return downloader_info
@router.get("/statistic", summary="媒体数量统计", response_model=schemas.Statistic)
def statistic(
name: Optional[str] = None, _: Any = Depends(get_current_active_superuser)
) -> Any:
"""
查询媒体数量统计信息
"""
return _build_statistic(name)
@router.get(
"/statistic2", summary="媒体数量统计API_TOKEN", response_model=schemas.Statistic
)
def statistic2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
"""
查询媒体数量统计信息 API_TOKEN认证?token=xxx
"""
return _build_statistic()
@router.get("/storage", summary="本地存储空间", response_model=schemas.Storage)
def storage(_: Any = Depends(get_current_active_superuser)) -> Any:
"""
查询本地存储空间信息
"""
return _build_storage()
@router.get(
"/storage2", summary="本地存储空间API_TOKEN", response_model=schemas.Storage
)
def storage2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
"""
查询本地存储空间信息 API_TOKEN认证?token=xxx
"""
return _build_storage()
@router.get("/processes", summary="进程信息", response_model=List[schemas.ProcessInfo])
def processes(_: Any = Depends(get_current_active_superuser)) -> Any:
"""
查询进程信息
"""
return SystemUtils.processes()
@router.get("/downloader", summary="下载器信息", response_model=schemas.DownloaderInfo)
def downloader(
name: Optional[str] = None, _: Any = Depends(get_current_active_superuser)
) -> Any:
"""
查询下载器信息
"""
return _build_downloader(name)
@router.get(
"/downloader2",
summary="下载器信息API_TOKEN",
@@ -126,11 +147,11 @@ def downloader2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
"""
查询下载器信息 API_TOKEN认证?token=xxx
"""
return downloader()
return _build_downloader()
@router.get("/schedule", summary="后台服务", response_model=List[schemas.ScheduleInfo])
async def schedule(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
async def schedule(_: Any = Depends(get_current_active_superuser)) -> Any:
"""
查询后台服务信息
"""
@@ -146,14 +167,14 @@ async def schedule2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
"""
查询下载器信息 API_TOKEN认证?token=xxx
"""
return await schedule()
return Scheduler().list()
@router.get("/transfer", summary="文件整理统计", response_model=List[int])
async def transfer(
days: Optional[int] = 7,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token),
_: Any = Depends(get_current_active_superuser),
) -> Any:
"""
查询文件整理统计信息
@@ -163,7 +184,7 @@ async def transfer(
@router.get("/cpu", summary="获取当前CPU使用率", response_model=float)
def cpu(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def cpu(_: Any = Depends(get_current_active_superuser)) -> Any:
"""
获取当前CPU使用率
"""
@@ -175,11 +196,11 @@ def cpu2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
"""
获取当前CPU使用率 API_TOKEN认证?token=xxx
"""
return cpu()
return SystemUtils.cpu_usage()
@router.get("/memory", summary="获取当前内存使用量和使用率", response_model=List[int])
def memory(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def memory(_: Any = Depends(get_current_active_superuser)) -> Any:
"""
获取当前内存使用率
"""
@@ -195,11 +216,11 @@ def memory2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
"""
获取当前内存使用率 API_TOKEN认证?token=xxx
"""
return memory()
return SystemUtils.memory_usage()
@router.get("/network", summary="获取当前网络流量", response_model=List[int])
def network(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def network(_: Any = Depends(get_current_active_superuser)) -> Any:
"""
获取当前网络流量上行和下行流量单位bytes/s
"""
@@ -213,4 +234,4 @@ def network2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
"""
获取当前网络流量 API_TOKEN认证?token=xxx
"""
return network()
return SystemUtils.network_usage()

View File

@@ -1,7 +1,7 @@
from datetime import timedelta
from typing import Any, List, Annotated
from fastapi import APIRouter, Depends, Form, HTTPException
from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response
from fastapi.security import OAuth2PasswordRequestForm
from app import schemas
@@ -18,6 +18,8 @@ router = APIRouter()
@router.post("/access-token", summary="获取token", response_model=schemas.Token)
def login_access_token(
request: Request,
response: Response,
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
otp_password: Annotated[str | None, Form()] = None,
) -> Any:
@@ -45,14 +47,27 @@ def login_access_token(
not SystemConfigOper().get(SystemConfigKey.SetupWizardState)
and not settings.ADVANCED_MODE
)
return schemas.Token(
access_token=security.create_access_token(
userid=user_or_message.id,
access_token = security.create_access_token(
userid=user_or_message.id,
username=user_or_message.name,
super_user=user_or_message.is_superuser,
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
level=level,
)
security.set_or_refresh_resource_token_cookie(
request,
response,
schemas.TokenPayload(
sub=user_or_message.id,
username=user_or_message.name,
super_user=user_or_message.is_superuser,
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
level=level,
purpose="authentication",
),
)
return schemas.Token(
access_token=access_token,
token_type="bearer",
super_user=user_or_message.is_superuser,
user_id=user_or_message.id,

View File

@@ -7,7 +7,7 @@ from datetime import timedelta
from typing import Any, Annotated, Optional
from app.helper.sites import SitesHelper
from fastapi import APIRouter, Depends, HTTPException, Body
from fastapi import APIRouter, Depends, HTTPException, Body, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from app import schemas
@@ -356,7 +356,9 @@ def passkey_authenticate_start(
summary="完成 PassKey 认证",
response_model=schemas.Token,
)
def passkey_authenticate_finish(passkey_req: PassKeyAuthenticationFinish) -> Any:
def passkey_authenticate_finish(
request: Request, response: Response, passkey_req: PassKeyAuthenticationFinish
) -> Any:
"""完成 PassKey 认证 - 验证凭证并返回 token"""
try:
# 提取并标准化凭证ID
@@ -393,14 +395,27 @@ def passkey_authenticate_finish(passkey_req: PassKeyAuthenticationFinish) -> Any
and not settings.ADVANCED_MODE
)
return schemas.Token(
access_token=security.create_access_token(
userid=user.id,
access_token = security.create_access_token(
userid=user.id,
username=user.name,
super_user=user.is_superuser,
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
level=level,
)
security.set_or_refresh_resource_token_cookie(
request,
response,
schemas.TokenPayload(
sub=user.id,
username=user.name,
super_user=user.is_superuser,
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
level=level,
purpose="authentication",
),
)
return schemas.Token(
access_token=access_token,
token_type="bearer",
super_user=user.is_superuser,
user_id=user.id,

View File

@@ -4,7 +4,7 @@ from typing import Annotated, Any, List, Optional
import aiofiles
from anyio import Path as AsyncPath
from fastapi import APIRouter, Depends, Header, HTTPException
from fastapi import APIRouter, Depends, Header, HTTPException, Security
from fastapi.concurrency import run_in_threadpool
from starlette import status
from starlette.responses import StreamingResponse
@@ -13,7 +13,12 @@ from app import schemas
from app.command import Command
from app.core.config import settings
from app.core.plugin import PluginManager
from app.core.security import verify_apikey, verify_token
from app.core.security import (
resource_token_cookie,
verify_apikey,
verify_resource_token,
verify_token,
)
from app.db.models import User
from app.db.systemconfig_oper import SystemConfigOper
from app.db.user_oper import (
@@ -163,6 +168,48 @@ def _merge_plugin_market_metadata(
return plugin
def _is_plugin_auth_remote_file(plugin_id: str, filepath: str) -> bool:
"""
判断静态文件是否属于插件声明的匿名登录认证远程组件。
登录页加载插件认证组件时尚未产生登录态和资源 Cookie因此仅对插件主动
声明的认证 remote 保留匿名读取能力,其余插件静态资源仍需资源令牌。
"""
path = filepath.lstrip("/")
normalized_plugin_id = plugin_id.lower()
plugin_manager = PluginManager()
for provider in plugin_manager.get_plugin_auth_providers():
remote = provider.get("remote") or {}
if str(remote.get("id") or "").lower() != normalized_plugin_id:
continue
remote_path = str(remote.get("url") or "").lstrip("/")
remote_path_lower = remote_path.lower()
expected_prefix = f"plugin/file/{normalized_plugin_id}/"
if not remote_path_lower.startswith(expected_prefix):
continue
remote_file = remote_path[len(expected_prefix):]
remote_dir = remote_file.rsplit("/", 1)[0] if "/" in remote_file else ""
if path == remote_file or (remote_dir and path.startswith(f"{remote_dir}/")):
return True
return False
def _verify_plugin_static_file_access(
plugin_id: str,
filepath: str,
resource_token: Annotated[Optional[str], Security(resource_token_cookie)] = None,
) -> None:
"""
校验插件静态文件访问权限。
普通插件资源依赖登录后写入的资源 Cookie登录认证插件的远程组件需要在
登录前加载,因此仅对插件声明的认证 remote 放行匿名读取。
"""
if _is_plugin_auth_remote_file(plugin_id, filepath):
return
verify_resource_token(resource_token)
async def _get_plugin_history_detail(
plugin_id: str, force: bool = True
) -> Optional[schemas.Plugin]:
@@ -440,7 +487,7 @@ def plugin_page(
@router.get("/dashboard/meta", summary="获取所有插件仪表板元信息")
def plugin_dashboard_meta(
_: schemas.TokenPayload = Depends(verify_token),
_: User = Depends(get_current_active_superuser),
) -> List[dict]:
"""
获取所有插件仪表板元信息
@@ -453,7 +500,7 @@ def plugin_dashboard_by_key(
plugin_id: str,
key: str,
user_agent: Annotated[str | None, Header()] = None,
_: schemas.TokenPayload = Depends(verify_token),
_: User = Depends(get_current_active_superuser),
) -> Optional[schemas.PluginDashboard]:
"""
根据插件ID获取插件仪表板
@@ -465,7 +512,7 @@ def plugin_dashboard_by_key(
def plugin_dashboard(
plugin_id: str,
user_agent: Annotated[str | None, Header()] = None,
_: schemas.TokenPayload = Depends(verify_token),
_: User = Depends(get_current_active_superuser),
) -> schemas.PluginDashboard:
"""
根据插件ID获取插件仪表板
@@ -493,7 +540,11 @@ def reset_plugin(
@router.get("/file/{plugin_id}/{filepath:path}", summary="获取插件静态文件")
async def plugin_static_file(plugin_id: str, filepath: str):
async def plugin_static_file(
plugin_id: str,
filepath: str,
_: None = Depends(_verify_plugin_static_file_access),
) -> StreamingResponse:
"""
获取插件静态文件
"""

View File

@@ -49,6 +49,19 @@ from version import APP_VERSION
router = APIRouter()
_NETTEST_REDIRECT_STATUS_CODES = {301, 302, 303, 307, 308}
_PUBLIC_SYSTEM_CONFIG_KEYS = {
item.value: item
for item in (
SystemConfigKey.Directories,
SystemConfigKey.Storages,
SystemConfigKey.IndexerSites,
SystemConfigKey.EpisodeFormatRuleTable,
SystemConfigKey.DefaultMovieSubscribeConfig,
SystemConfigKey.DefaultTvSubscribeConfig,
SystemConfigKey.FollowSubscribers,
)
}
_PUBLIC_SETTINGS_KEYS = {"PLUGIN_MARKET"}
def _match_nettest_prefix(url: str, prefix: str) -> bool:
@@ -501,7 +514,9 @@ async def get_user_global_setting(_: User = Depends(get_current_active_user_asyn
@router.get("/env", summary="查询系统配置", response_model=schemas.Response)
async def get_env_setting(_: User = Depends(get_current_active_user_async)):
async def get_env_setting(
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
"""
查询系统环境变量,包括当前版本号(仅管理员)
"""
@@ -527,6 +542,14 @@ async def usage_statistic(_: User = Depends(get_current_active_user_async)):
return schemas.Response(success=True, data=await MoviePilotServerHelper.async_get_usage_statistic())
@router.get("/ping", summary="服务存活检测", response_model=schemas.Response)
async def ping(_: User = Depends(get_current_active_user_async)) -> schemas.Response:
"""
检测服务是否可用
"""
return schemas.Response(success=True)
@router.post("/env", summary="更新系统配置", response_model=schemas.Response)
async def set_env_setting(
env: dict, _: User = Depends(get_current_active_superuser_async)
@@ -587,8 +610,25 @@ async def get_progress(
return StreamingResponse(event_generator(), media_type="text/event-stream")
@router.get("/setting/public/{key}", summary="查询公开系统设置", response_model=schemas.Response)
async def get_public_setting(
key: str, _: User = Depends(get_current_active_user_async)
) -> schemas.Response:
"""
查询普通用户可读取的非敏感系统设置
"""
if key in _PUBLIC_SETTINGS_KEYS:
return schemas.Response(success=True, data={"value": getattr(settings, key)})
if key not in _PUBLIC_SYSTEM_CONFIG_KEYS:
raise HTTPException(status_code=404, detail="配置项不存在")
value = SystemConfigOper().get(_PUBLIC_SYSTEM_CONFIG_KEYS[key])
return schemas.Response(success=True, data={"value": value})
@router.get("/setting/{key}", summary="查询系统设置", response_model=schemas.Response)
async def get_setting(key: str, _: User = Depends(get_current_active_user_async)):
async def get_setting(
key: str, _: User = Depends(get_current_active_superuser_async)
) -> schemas.Response:
"""
查询系统设置(仅管理员)
"""

View File

@@ -104,11 +104,14 @@ async def upload_avatar(
user_id: int,
db: AsyncSession = Depends(get_async_db),
file: UploadFile = File(...),
_: User = Depends(get_current_active_user_async),
):
current_user: User = Depends(get_current_active_user_async),
) -> schemas.Response:
"""
上传用户头像
"""
if current_user.id != user_id and not current_user.is_superuser:
raise HTTPException(status_code=400, detail="用户权限不足")
# 将文件转换为Base64
file_base64 = base64.b64encode(file.file.read())
# 更新到用户表

View File

@@ -148,7 +148,9 @@ def create_access_token(
return encoded_jwt
def __set_or_refresh_resource_token_cookie(request: Request, response: Response, payload: schemas.TokenPayload):
def set_or_refresh_resource_token_cookie(
request: Request, response: Response, payload: schemas.TokenPayload
) -> None:
"""
设置资源令牌 Cookie
:param request: 包含请求相关的上下文数据
@@ -258,7 +260,7 @@ def verify_token(
payload = __verify_token(token=jwt_token, purpose="authentication")
# 如果没有 resource_token生成并写入到 Cookie
__set_or_refresh_resource_token_cookie(request, response, payload)
set_or_refresh_resource_token_cookie(request, response, payload)
return payload
elif api_key:

View File

@@ -108,6 +108,13 @@ MoviePilot 也提供普通 REST API 给前端和自动化客户端使用。所
| GET | `/api/v1/download/paths` | 查询可用于下载接口 `save_path` 参数的下载路径 |
| DELETE | `/api/v1/download/{hashString}` | 删除下载任务,参数:`name` |
#### 系统
| 方法 | 路径 | 说明 |
| :--- | :--- | :--- |
| GET | `/api/v1/system/ping` | 登录用户服务存活检测,用于前端重启后轮询恢复状态 |
| GET | `/api/v1/system/setting/public/{key}` | 登录用户读取白名单内非敏感系统设置仅支持目录、存储、站点范围、默认订阅规则、Follow 订阅者和插件市场地址等前端必需配置 |
### 插件补充接口
**GET** `/api/v1/plugin/history/{plugin_id}`

View File

@@ -320,12 +320,14 @@ All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as `
| POST | `/api/v1/workflow/fork` | Fork shared workflow. Body: WorkflowShare JSON |
| GET | `/api/v1/workflow/shares` | List shared workflows. Params: `name`, `page`, `count` |
### System (21 endpoints)
### System (23 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/system/env` | Get system configuration, including runtime versions and Rust acceleration availability/enabled status |
| POST | `/api/v1/system/env` | Update system configuration. Body: JSON object |
| GET | `/api/v1/system/ping` | Check service availability for authenticated users |
| GET | `/api/v1/system/setting/public/{key}` | Get allowlisted non-sensitive system setting for authenticated users |
| GET | `/api/v1/system/setting/{key}` | Get system setting |
| POST | `/api/v1/system/setting/{key}` | Update system setting |
| GET | `/api/v1/system/global` | Non-sensitive settings. Params: `token` (required) |

View File

@@ -0,0 +1,242 @@
import asyncio
import io
import inspect
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from starlette.requests import Request
from starlette.responses import Response
from app.api.endpoints import login as login_endpoint
from app.api.endpoints import plugin as plugin_endpoint
from app.api.endpoints import system as system_endpoint
from app.api.endpoints import user as user_endpoint
from app.api.endpoints import dashboard as dashboard_endpoint
from app.core.security import verify_resource_token
from app.db.user_oper import (
get_current_active_superuser,
get_current_active_superuser_async,
get_current_active_user_async,
)
from app.schemas.types import SystemConfigKey
def _dependency_of(func, parameter_name: str):
"""读取 FastAPI 函数参数上声明的依赖函数。"""
return inspect.signature(func).parameters[parameter_name].default.dependency
def _build_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 test_system_sensitive_read_endpoints_require_superuser():
"""系统敏感读取接口必须只允许管理员访问。"""
assert _dependency_of(system_endpoint.get_env_setting, "_") is get_current_active_superuser_async
assert _dependency_of(system_endpoint.get_setting, "_") is get_current_active_superuser_async
def test_system_public_read_endpoints_require_active_user():
"""公开读取接口只要求登录且启用的用户。"""
assert _dependency_of(system_endpoint.ping, "_") is get_current_active_user_async
assert _dependency_of(system_endpoint.get_public_setting, "_") is get_current_active_user_async
def test_dashboard_endpoints_require_superuser():
"""仪表板页面相关接口必须只允许管理员访问。"""
assert _dependency_of(dashboard_endpoint.statistic, "_") is get_current_active_superuser
assert _dependency_of(dashboard_endpoint.storage, "_") is get_current_active_superuser
assert _dependency_of(dashboard_endpoint.processes, "_") is get_current_active_superuser
assert _dependency_of(dashboard_endpoint.downloader, "_") is get_current_active_superuser
assert _dependency_of(dashboard_endpoint.schedule, "_") is get_current_active_superuser
assert _dependency_of(dashboard_endpoint.transfer, "_") is get_current_active_superuser
assert _dependency_of(dashboard_endpoint.cpu, "_") is get_current_active_superuser
assert _dependency_of(dashboard_endpoint.memory, "_") is get_current_active_superuser
assert _dependency_of(dashboard_endpoint.network, "_") is get_current_active_superuser
def test_plugin_dashboard_endpoints_require_superuser():
"""插件仪表板接口必须只允许管理员访问。"""
assert _dependency_of(plugin_endpoint.plugin_dashboard_meta, "_") is get_current_active_superuser
assert _dependency_of(plugin_endpoint.plugin_dashboard_by_key, "_") is get_current_active_superuser
assert _dependency_of(plugin_endpoint.plugin_dashboard, "_") is get_current_active_superuser
def test_system_public_setting_allows_only_non_sensitive_keys(monkeypatch):
"""公开系统设置接口只能读取明确列入白名单的非敏感配置。"""
calls = []
class FakeSystemConfigOper:
"""返回测试配置值的系统配置桩。"""
def get(self, key):
"""返回测试配置值。"""
calls.append(key)
return [{"path": "/downloads"}]
monkeypatch.setattr(system_endpoint, "SystemConfigOper", FakeSystemConfigOper)
response = asyncio.run(
system_endpoint.get_public_setting(SystemConfigKey.Directories.value)
)
assert response.success is True
assert response.data == {"value": [{"path": "/downloads"}]}
assert calls == [SystemConfigKey.Directories]
response = asyncio.run(system_endpoint.get_public_setting("PLUGIN_MARKET"))
assert response.success is True
assert response.data == {"value": system_endpoint.settings.PLUGIN_MARKET}
assert calls == [SystemConfigKey.Directories]
with pytest.raises(HTTPException) as exc_info:
asyncio.run(system_endpoint.get_public_setting("API_TOKEN"))
assert exc_info.value.status_code == 404
assert exc_info.value.detail == "配置项不存在"
def test_system_ping_returns_success():
"""服务存活检测接口返回标准成功响应。"""
response = asyncio.run(system_endpoint.ping())
assert response.success is True
def test_login_sets_resource_token_cookie(monkeypatch):
"""登录成功时应立即写入资源 Cookie避免插件静态文件抢先加载失败。"""
class FakeUserChain:
"""返回登录成功用户的用户链桩。"""
def user_authenticate(self, username, password, mfa_code=None):
"""返回认证成功结果。"""
return True, SimpleNamespace(
id=1,
name=username,
is_superuser=False,
avatar="",
permissions={"discovery": True},
)
class FakeSystemConfigOper:
"""返回已完成向导状态的系统配置桩。"""
def get(self, key):
"""返回测试配置值。"""
return "1"
form_data = SimpleNamespace(username="user", password="password")
request = _build_request()
response = Response()
monkeypatch.setattr(login_endpoint, "UserChain", FakeUserChain)
monkeypatch.setattr(login_endpoint, "SystemConfigOper", FakeSystemConfigOper)
token = login_endpoint.login_access_token(
request=request,
response=response,
form_data=form_data,
)
assert token.user_id == 1
assert token.permissions == {"discovery": True}
assert "set-cookie" in response.headers
resource_cookie = response.headers["set-cookie"].split("=", 1)[1].split(";", 1)[0]
payload = verify_resource_token(resource_cookie)
assert payload.sub == 1
assert payload.username == "user"
assert payload.purpose == "resource"
def test_plugin_static_file_requires_resource_token_by_default(monkeypatch):
"""普通插件静态资源必须校验资源令牌。"""
calls = []
class FakePluginManager:
"""返回空认证提供方的插件管理器桩。"""
def get_plugin_auth_providers(self):
"""返回插件认证入口列表。"""
return []
monkeypatch.setattr(plugin_endpoint, "PluginManager", FakePluginManager)
monkeypatch.setattr(plugin_endpoint, "verify_resource_token", lambda token: calls.append(token))
plugin_endpoint._verify_plugin_static_file_access(
plugin_id="DemoPlugin",
filepath="dist/remoteEntry.js",
resource_token="resource-token",
)
assert calls == ["resource-token"]
def test_plugin_auth_remote_files_allow_anonymous_bootstrap(monkeypatch):
"""插件登录认证远程组件需要允许登录前匿名加载。"""
calls = []
class FakePluginManager:
"""返回认证插件 remote 信息的插件管理器桩。"""
def get_plugin_auth_providers(self):
"""返回插件认证入口列表。"""
return [
{
"remote": {
"id": "AuthPlugin",
"url": "/plugin/file/AuthPlugin/dist/remoteEntry.js",
}
}
]
monkeypatch.setattr(plugin_endpoint, "PluginManager", FakePluginManager)
monkeypatch.setattr(plugin_endpoint, "verify_resource_token", lambda token: calls.append(token))
plugin_endpoint._verify_plugin_static_file_access(
plugin_id="AuthPlugin",
filepath="dist/remoteEntry.js",
)
plugin_endpoint._verify_plugin_static_file_access(
plugin_id="AuthPlugin",
filepath="dist/assets/chunk.js",
)
plugin_endpoint._verify_plugin_static_file_access(
plugin_id="authplugin",
filepath="dist/assets/chunk.js",
)
assert calls == []
def test_upload_avatar_rejects_other_user_for_non_superuser():
"""普通用户不能通过 user_id 参数修改其他用户头像。"""
current_user = SimpleNamespace(id=1, is_superuser=False)
upload_file = SimpleNamespace(file=io.BytesIO(b"avatar"), filename="avatar.png")
with pytest.raises(HTTPException) as exc_info:
asyncio.run(
user_endpoint.upload_avatar(
user_id=2,
db=object(),
file=upload_file,
current_user=current_user,
)
)
assert exc_info.value.status_code == 400
assert exc_info.value.detail == "用户权限不足"