diff --git a/app/api/endpoints/subscribe.py b/app/api/endpoints/subscribe.py index a320f1fed..96127903a 100644 --- a/app/api/endpoints/subscribe.py +++ b/app/api/endpoints/subscribe.py @@ -230,15 +230,20 @@ async def read_subscribes( openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True}, ) async def list_subscribes( + _: Annotated[str, Depends(verify_apitoken)], response: Response = None, # type: ignore[assignment] query: SubscriptionQueryService = Depends(get_subscription_query_service), status_service: SubscriptionExecutionStatusService = Depends(get_subscription_execution_status_service), - _: Optional[Annotated[str, Depends(verify_apitoken)]] = None, page: CompatiblePageParam = None, count: CompatibleCountParam = None, ) -> Any: """ - 查询所有订阅 API_TOKEN认证(?token=xxx) + 通过 API_TOKEN 及当前管理员身份校验后查询全部用户的订阅。 + + 认证依赖必须放在最外层 Annotated 中,避免被可空联合类型隐藏。 + + :param _: 由 ?token= 或 X-API-KEY 传入并已通过校验的凭据 + :return: 按可选分页返回的订阅列表 """ page, count = resolve_compatible_pagination(page, count) if response is not None: diff --git a/docs/mcp-api.md b/docs/mcp-api.md index d26e7e4ac..11d0902b4 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -102,6 +102,11 @@ JSON 使用十进制字符串避免浏览器数字精度丢失,客户端应原 - 这是**管理员级集成入口**,可以访问站点、配置、数据库及有副作用的操作。只向可信客户端提供密钥,并使用 HTTPS 或受信网络。 - 写操作的用户授权由调用方负责取得;不要假设 MCP 会弹出 MoviePilot 内置 Agent 的确认界面。隐藏工具也不构成权限隔离。 +REST 兼容集成接口 `GET /api/v1/subscribe/list` 使用 `X-API-KEY: `, +也接受 `?token=`;缺失、错误凭据或绑定的管理员身份失效时返回 HTTP `401`。 +认证成功后可读取全部用户的订阅及总量。Agent 的 `subscription.list` 使用当前用户身份访问 +`GET /api/v1/subscribe/`,普通用户只能读取自己的订阅。 + 下文的 HTTP 示例使用两个占位环境变量,由接入方在自己的环境中设置。不要把真实密钥写进文档、模型提示词或代码仓库。 ```bash diff --git a/scripts/generate_agent_skill_docs.py b/scripts/generate_agent_skill_docs.py index 72e0a54ac..dd7a77cd5 100644 --- a/scripts/generate_agent_skill_docs.py +++ b/scripts/generate_agent_skill_docs.py @@ -17,7 +17,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1] if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) -MOVIEPILOT_API_SKILL_VERSION = "65" +MOVIEPILOT_API_SKILL_VERSION = "66" DATABASE_OPERATION_SKILL_VERSION = "16" diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index 6a9c13050..07ac627a5 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -1,6 +1,6 @@ --- name: moviepilot-api -version: 65 +version: 66 description: >- Use this skill for MoviePilot product operations such as media search, torrent search, downloads, subscriptions, library checks, sites, storage, workflows, @@ -103,6 +103,11 @@ users may update or delete only their own subscriptions. An unbound or inactive channel user must bind an active account before retrying, not switch to an administrator identity. +`subscription.list` uses the current user identity and subscription ownership filtering. +The separate global subscription integration endpoint requires a valid API_TOKEN +and administrator identity. It is outside this skill's allowed operations and must +never be used to bypass user scope. + For QQ, either `qq_userid` or `qq_openid` may match the current channel ID; for Feishu, either `feishu_userid` or `feishu_openid` may match. A single matching field is sufficient, but the match must identify exactly one active MoviePilot diff --git a/tests/fixtures/architecture/mypy-policy.json b/tests/fixtures/architecture/mypy-policy.json index 8b64c1d49..282790f1f 100644 --- a/tests/fixtures/architecture/mypy-policy.json +++ b/tests/fixtures/architecture/mypy-policy.json @@ -624,8 +624,8 @@ "codes": [ "assignment" ], - "fingerprint": "b36833696f77492798f44e25bc64c613b3a5b6e232a3df2d2337475afa2c30fa", - "statement": "@router.get('/list', summary='查询所有订阅(API_TOKEN)', response_model=List[_SchemaSubscribe], openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True})\nasync def list_subscribes(response: Response=None, query: SubscriptionQueryService=Depends(get_subscription_query_service), status_service: SubscriptionExecutionStatusService=Depends(get_subscription_execution_status_service), _: Optional[Annotated[str, Depends(verify_apitoken)]]=None, page: CompatiblePageParam=None, count: CompatibleCountParam=None) -> Any:", + "fingerprint": "9013a36a2221a1c539535d5cfb8a7ee7cde3fe3f6e0a15fb2e20f0a58a2db052", + "statement": "@router.get('/list', summary='查询所有订阅(API_TOKEN)', response_model=List[_SchemaSubscribe], openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True})\nasync def list_subscribes(_: Annotated[str, Depends(verify_apitoken)], response: Response=None, query: SubscriptionQueryService=Depends(get_subscription_query_service), status_service: SubscriptionExecutionStatusService=Depends(get_subscription_execution_status_service), page: CompatiblePageParam=None, count: CompatibleCountParam=None) -> Any:", "reason": "fastapi-response-default" }, { diff --git a/tests/test_builtin_skill_boundaries.py b/tests/test_builtin_skill_boundaries.py index 89c79ad40..594250fe3 100644 --- a/tests/test_builtin_skill_boundaries.py +++ b/tests/test_builtin_skill_boundaries.py @@ -130,7 +130,7 @@ def test_modified_builtin_skills_have_incremented_versions() -> None: "database-operation": "16", "feedback-issue": "12", "anysearch": "3", - "moviepilot-api": "65", + "moviepilot-api": "66", "moviepilot-update": "7", "organize-files": "6", "transfer-failed-retry": "6", diff --git a/tests/test_subscribe_api_auth.py b/tests/test_subscribe_api_auth.py new file mode 100644 index 000000000..a993bea93 --- /dev/null +++ b/tests/test_subscribe_api_auth.py @@ -0,0 +1,128 @@ +""" +验证订阅全量查询路由真实执行 API_TOKEN 认证后才读取数据。 +""" + +from collections.abc import Iterator +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.adapters.web.security import access +from app.api.endpoints import subscribe as subscribe_endpoint +from app.schemas.subscribe import Subscribe +from app.schemas.token import TokenPayload + + +@pytest.fixture +def subscribe_api(monkeypatch) -> Iterator[SimpleNamespace]: + """ + 保留真实认证依赖,仅替换配置、管理员身份端口和订阅查询边界。 + + :param monkeypatch: 用例结束后恢复配置读取及身份端口的 pytest 夹具 + :yields: 独立 HTTP 客户端及用于验证读取和身份校验的替身 + """ + monkeypatch.setattr(access, "get_runtime_setting", lambda _key: "test-subscription-api-token") + identity = Mock(return_value=TokenPayload(sub=7, username="admin", super_user=True)) + monkeypatch.setattr(access, "_superuser_token_payload_provider", identity) + query = SimpleNamespace( + count_public=AsyncMock(return_value=2), + list_public=AsyncMock(return_value=[ + Subscribe(id=1, name="订阅一", username="alice"), + Subscribe(id=2, name="订阅二", username="bob"), + ]), + ) + status = SimpleNamespace(for_subscriptions=AsyncMock(return_value={})) + app = FastAPI() + app.include_router(subscribe_endpoint.router, prefix="/api/v1/subscribe") + app.dependency_overrides[subscribe_endpoint.get_subscription_query_service] = lambda: query + app.dependency_overrides[subscribe_endpoint.get_subscription_execution_status_service] = lambda: status + + with TestClient(app) as client: + yield SimpleNamespace(client=client, query=query, status=status, identity=identity) + + +@pytest.mark.parametrize( + ("params", "headers"), + [ + ({}, {}), + ({"token": ""}, {}), + ({"token": "wrong-token"}, {}), + ({}, {"X-API-KEY": "wrong-token"}), + ({"_": "test-subscription-api-token"}, {}), + ({"token": "test-subscription-api-token"}, {"X-API-KEY": "wrong-token"}), + ], +) +def test_subscribe_list_rejects_invalid_credentials_before_reading(subscribe_api, params, headers) -> None: + """ + 缺失或错误凭据必须返回 401,不能查询订阅总量、数据或执行状态。 + """ + response = subscribe_api.client.get("/api/v1/subscribe/list", params=params, headers=headers) + + assert response.status_code == 401 + assert response.headers["www-authenticate"] == "Bearer" + assert response.json() == {"detail": "token 校验不通过"} + subscribe_api.query.count_public.assert_not_awaited() + subscribe_api.query.list_public.assert_not_awaited() + subscribe_api.status.for_subscriptions.assert_not_awaited() + subscribe_api.identity.assert_not_called() + + +@pytest.mark.parametrize( + ("params", "headers"), + [ + ({"token": "test-subscription-api-token"}, {}), + ({}, {"X-API-KEY": "test-subscription-api-token"}), + ({"token": "wrong-token"}, {"X-API-KEY": "test-subscription-api-token"}), + ], +) +def test_subscribe_list_accepts_valid_credentials_and_preserves_global_query(subscribe_api, params, headers) -> None: + """ + 正确凭据通过当前管理员校验后,仍按既有分页合同读取全部用户订阅。 + """ + response = subscribe_api.client.get( + "/api/v1/subscribe/list", + params={**params, "page": 1, "count": 2}, + headers=headers, + ) + + assert response.status_code == 200 + assert response.headers["x-total-count"] == "2" + assert [(item["id"], item["username"]) for item in response.json()["data"]] == [(1, "alice"), (2, "bob")] + subscribe_api.query.count_public.assert_awaited_once_with() + subscribe_api.query.list_public.assert_awaited_once_with(page=1, count=2) + subscribe_api.status.for_subscriptions.assert_awaited_once_with((1, 2)) + subscribe_api.identity.assert_called_once_with() + + +def test_subscribe_list_rejects_invalid_admin_identity_before_reading(subscribe_api) -> None: + """ + 正确 API_TOKEN 也不能绕过所绑定管理员的当前身份校验。 + """ + subscribe_api.identity.side_effect = PermissionError("用户不存在或已禁用") + + response = subscribe_api.client.get( + "/api/v1/subscribe/list", params={"token": "test-subscription-api-token"} + ) + + assert response.status_code == 401 + assert response.headers["www-authenticate"] == "Bearer" + subscribe_api.identity.assert_called_once_with() + subscribe_api.query.count_public.assert_not_awaited() + subscribe_api.query.list_public.assert_not_awaited() + subscribe_api.status.for_subscriptions.assert_not_awaited() + + +def test_subscribe_list_openapi_declares_authentication(subscribe_api) -> None: + """ + OpenAPI 应声明 API 凭据,不能把认证占位参数暴露为普通查询参数。 + """ + response = subscribe_api.client.get("/openapi.json") + + assert response.status_code == 200 + operation = response.json()["paths"]["/api/v1/subscribe/list"]["get"] + assert {"api_token_query": []} in operation["security"] + assert {"api_key_header": []} in operation["security"] + assert "_" not in {parameter["name"] for parameter in operation.get("parameters", [])}