diff --git a/app/startup/composition/agent.py b/app/startup/composition/agent.py index 3d06fd9a9..cfffc74d0 100644 --- a/app/startup/composition/agent.py +++ b/app/startup/composition/agent.py @@ -11,6 +11,7 @@ from app.application.agent import AgentDataContext from app.application.agenttask import ( AgentTaskExecutionService, configure_agent_task_execution, + reset_agent_task_execution, ) from app.application.history import DownloadHistoryRepository, TransferHistoryRepository from app.application.messaging.chat import ( @@ -18,6 +19,8 @@ from app.application.messaging.chat import ( AgentChatService, configure_agent_chat_persistence, configure_agent_chat_service, + reset_agent_chat_persistence, + reset_agent_chat_service, ) from app.application.site.contract import SiteRepository from app.application.subscription.contract import ( @@ -49,6 +52,7 @@ from app.startup.composition.subscription import ( ) AgentDataContextRegistrar = Callable[[AgentDataContext], None] +AgentDataContextResetter = Callable[[], None] @dataclass(frozen=True, slots=True) @@ -128,3 +132,14 @@ def publish_agent_services( configure_agent_chat_persistence(composition.data.chat_persistence) configure_agent_task_execution(composition.execution) data_context_registrar(composition.data) + + +def reset_agent_services( + *, + data_context_resetter: AgentDataContextResetter, +) -> None: + """按发布逆序撤销当前 lifespan 的 Agent 数据、任务与会话服务。""" + data_context_resetter() + reset_agent_task_execution() + reset_agent_chat_persistence() + reset_agent_chat_service() diff --git a/app/startup/composition/chain.py b/app/startup/composition/chain.py index 3d587a1bc..2162533d9 100644 --- a/app/startup/composition/chain.py +++ b/app/startup/composition/chain.py @@ -8,7 +8,10 @@ from app.application.chain.context import ( configure_chain_runtime_context_provider, ) from app.application.configuration import ChainRuntimeConfig -from app.application.image import configure_wallpaper_providers +from app.application.image import ( + configure_wallpaper_providers, + reset_wallpaper_providers, +) from app.application.messaging.message import MessageHelper, MessageQueueManager from app.application.transfer.execution import TransferExecutionRepository from app.chain.mediaserver import MediaServerChain @@ -139,3 +142,9 @@ def configure_chain_runtime_context( configuration=configuration, ) ) + + +def reset_chain_services() -> None: + """按发布逆序撤销 Chain 无参上下文和壁纸来源。""" + configure_chain_runtime_context_provider(None) + reset_wallpaper_providers() diff --git a/app/startup/composition/network.py b/app/startup/composition/network.py index 8fffaeb37..105e5fb6f 100644 --- a/app/startup/composition/network.py +++ b/app/startup/composition/network.py @@ -11,16 +11,19 @@ from app.application.image import ( ImageTransport, InternalAddressPort, configure_image_ports, + reset_image_ports, ) from app.application.messaging.ingress import ( MessageIngressPort, configure_message_ingress_port, + reset_message_ingress_port, ) from app.application.network import ( NetworkTestResponse, NetworkTestService, NetworkTestTransport, configure_network_test_service, + reset_network_test_service, ) from app.runtime.log import logger @@ -155,3 +158,10 @@ def configure_application_network_ports() -> None: internal_address=internal_address, ) configure_message_ingress_port(message_ingress) + + +def reset_application_network_ports() -> None: + """按发布逆序撤销消息回环、图片与网络探测应用端口。""" + reset_message_ingress_port() + reset_image_ports() + reset_network_test_service() diff --git a/app/startup/composition/outbox.py b/app/startup/composition/outbox.py index f86e021a6..39a870cb2 100644 --- a/app/startup/composition/outbox.py +++ b/app/startup/composition/outbox.py @@ -11,6 +11,7 @@ from app.application.outbox import ( ClaimedOutboxMessage, OutboxDispatcher, durable_event_topic, + reset_outbox_dispatcher, validate_durable_event_handlers, ) from app.command import CommandChain @@ -154,3 +155,8 @@ def build_outbox_dispatcher() -> OutboxDispatcher: owner="outbox", ), ) + + +def reset_outbox_services() -> None: + """撤销当前 lifespan 发布的 Outbox dispatcher 工厂。""" + reset_outbox_dispatcher() diff --git a/app/startup/composition/security.py b/app/startup/composition/security.py index a7c388ff9..eb1414ee1 100644 --- a/app/startup/composition/security.py +++ b/app/startup/composition/security.py @@ -1,19 +1,25 @@ """认证、用户与 PassKey 服务的启动组合。""" -from app.adapters.web.security.access import set_superuser_token_payload_provider +from app.adapters.web.security.access import ( + reset_superuser_token_payload_provider, + set_superuser_token_payload_provider, +) from app.application.configuration import get_configured_system_config from app.application.security.auth import ( AuthService, build_superuser_token_payload, configure_auth_service, + reset_auth_service, ) from app.application.security.passkey import ( PASSKEY_CHALLENGE_TTL_SECONDS, PasskeyService, configure_passkey_challenge_cache, configure_passkey_service, + reset_passkey_challenge_cache, + reset_passkey_service, ) -from app.application.security.user import configure_user_lookups +from app.application.security.user import configure_user_lookups, reset_user_lookups from app.db.adapters.user import SqlAlchemyUserRepository from app.db.oper.passkey import PassKeyOper from app.db.oper.systemconfig import SystemConfigOper @@ -57,3 +63,16 @@ def configure_security_services() -> AuthenticationRuntime: def configure_security_access() -> None: """登记 Web 认证边界所需的超级用户令牌载荷提供器。""" set_superuser_token_payload_provider(build_superuser_token_payload) + + +def reset_security_services() -> None: + """按发布逆序撤销认证、用户查询和 PassKey 服务。""" + reset_passkey_service() + reset_passkey_challenge_cache() + reset_auth_service() + reset_user_lookups() + + +def reset_security_access() -> None: + """撤销 Web 认证边界的超级用户令牌载荷提供器及其缓存。""" + reset_superuser_token_payload_provider() diff --git a/app/startup/composition/server.py b/app/startup/composition/server.py index df1dc0a56..68e383db4 100644 --- a/app/startup/composition/server.py +++ b/app/startup/composition/server.py @@ -3,6 +3,7 @@ from app.adapters.external.server import ( MoviePilotServerHelper, configure_server_application_services, + reset_server_application_services, ) from app.application.configuration import get_configured_system_config from app.application.server.report import ServerReportService @@ -48,3 +49,8 @@ def configure_server_services( workflow_cache_clearer=MoviePilotServerHelper._clear_workflow_share_cache, ), ) + + +def reset_server_services() -> None: + """撤销当前 lifespan 发布的中心服务上报与分享用例。""" + reset_server_application_services() diff --git a/tests/test_composition_owner_resets.py b/tests/test_composition_owner_resets.py new file mode 100644 index 000000000..695a54697 --- /dev/null +++ b/tests/test_composition_owner_resets.py @@ -0,0 +1,261 @@ +"""验证 composition owner 对称撤销其发布的 lifespan provider。""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from fastapi import HTTPException + +from app.adapters.external import server as server_provider +from app.adapters.web.security import access as access_provider +from app.application import agenttask as task_provider +from app.application import image as image_provider +from app.application import network as network_provider +from app.application import outbox as outbox_provider +from app.application.chain import context as chain_provider +from app.application.messaging import chat as chat_provider +from app.application.messaging import ingress as ingress_provider +from app.application.security import auth as auth_provider +from app.application.security import passkey as passkey_provider +from app.application.security import user as user_provider +from app.startup.composition import agent as agent_composition +from app.startup.composition import chain as chain_composition +from app.startup.composition import network as network_composition +from app.startup.composition import outbox as outbox_composition +from app.startup.composition import security as security_composition +from app.startup.composition import server as server_composition + + +def test_agent_owner_reset_is_symmetric_and_idempotent(monkeypatch) -> None: + """Agent owner 撤销数据上下文、任务执行与两类会话服务。""" + monkeypatch.setattr(chat_provider, "_configured_agent_chat_service", None) + monkeypatch.setattr(chat_provider, "_configured_agent_chat_persistence", None) + monkeypatch.setattr(task_provider, "_service", None) + data_state: dict[str, object] = {} + composition = SimpleNamespace( + data=SimpleNamespace(chat=object(), chat_persistence=object()), + execution=object(), + ) + + agent_composition.publish_agent_services( + composition, + data_context_registrar=lambda data: data_state.update(current=data), + ) + assert chat_provider.get_configured_agent_chat_service() is composition.data.chat + assert ( + chat_provider.get_configured_agent_chat_persistence() + is composition.data.chat_persistence + ) + assert task_provider.get_agent_task_execution_service() is composition.execution + assert data_state["current"] is composition.data + + def reset_data() -> None: + """撤销测试中的 Agent 数据上下文。""" + data_state.pop("current", None) + + agent_composition.reset_agent_services(data_context_resetter=reset_data) + agent_composition.reset_agent_services(data_context_resetter=reset_data) + + assert data_state == {} + for getter in ( + chat_provider.get_configured_agent_chat_service, + chat_provider.get_configured_agent_chat_persistence, + task_provider.get_agent_task_execution_service, + ): + with pytest.raises(RuntimeError): + getter() + + +def test_security_owner_reset_is_symmetric_and_idempotent(monkeypatch) -> None: + """Security owner 撤销身份查询、认证、PassKey 与 Web 访问 provider。""" + monkeypatch.setattr(user_provider, "_configured_user_id_lookup", None) + monkeypatch.setattr(user_provider, "_configured_user_name_lookup", None) + monkeypatch.setattr(user_provider, "_configured_user_channel_lookup", None) + monkeypatch.setattr(auth_provider, "_configured_auth_service", None) + monkeypatch.setattr(passkey_provider.PasskeyChallengeStore, "_cache", None) + monkeypatch.setattr(passkey_provider, "_configured_passkey_service", None) + monkeypatch.setattr(access_provider, "_superuser_token_payload_provider", None) + access_provider._create_superuser_token_payload.cache_clear() + + user_provider.configure_user_lookups( + by_id=lambda _user_id: object(), + by_name=lambda _username: object(), + by_channel=lambda **_bindings: "user", + ) + auth_provider.configure_auth_service(object()) # type: ignore[arg-type] + passkey_provider.configure_passkey_challenge_cache(object()) # type: ignore[arg-type] + passkey_provider.configure_passkey_service(object()) # type: ignore[arg-type] + access_provider.set_superuser_token_payload_provider(lambda: object()) + + security_composition.reset_security_access() + security_composition.reset_security_access() + security_composition.reset_security_services() + security_composition.reset_security_services() + + for getter in ( + user_provider.get_configured_user_id_lookup, + user_provider.get_configured_user_name_lookup, + user_provider.get_configured_user_channel_lookup, + auth_provider.get_configured_auth_service, + passkey_provider.get_configured_passkey_service, + passkey_provider.PasskeyChallengeStore._get_cache, + ): + with pytest.raises(RuntimeError): + getter() + with pytest.raises(HTTPException) as error: + access_provider._create_superuser_token_payload() + assert error.value.status_code == 503 + + +def test_network_owner_reset_is_symmetric_and_idempotent(monkeypatch) -> None: + """Network owner 撤销探测、图片和消息回环端口。""" + monkeypatch.setattr(network_provider, "_configured_network_test_service", None) + monkeypatch.setattr(image_provider, "_image_transport", None) + monkeypatch.setattr(image_provider, "_internal_address", None) + monkeypatch.setattr(ingress_provider, "_message_ingress_port", None) + + network_provider.configure_network_test_service(object()) # type: ignore[arg-type] + image_provider.configure_image_ports( + transport=object(), # type: ignore[arg-type] + internal_address=object(), # type: ignore[arg-type] + ) + ingress_provider.configure_message_ingress_port(object()) # type: ignore[arg-type] + + network_composition.reset_application_network_ports() + network_composition.reset_application_network_ports() + + for getter in ( + network_provider.get_configured_network_test_service, + image_provider._image_ports_snapshot, + ingress_provider._message_ingress_snapshot, + ): + with pytest.raises(RuntimeError): + getter() + + +def test_outbox_owner_reset_is_symmetric_and_idempotent(monkeypatch) -> None: + """Outbox owner 撤销 dispatcher 工厂并恢复显式未配置错误。""" + monkeypatch.setattr(outbox_provider, "_configured_dispatcher", None) + outbox_provider.configure_outbox_dispatcher(Mock()) + + outbox_composition.reset_outbox_services() + outbox_composition.reset_outbox_services() + + with pytest.raises(RuntimeError, match="Outbox dispatcher 尚未配置"): + outbox_provider.dispatch_pending_outbox() + + +def test_server_owner_reset_is_symmetric_and_idempotent(monkeypatch) -> None: + """Server owner 同时撤销上报与分享服务。""" + monkeypatch.setattr(server_provider, "_server_report_service", None) + monkeypatch.setattr(server_provider, "_server_sharing_service", None) + server_provider.configure_server_application_services( + report_service=object(), + sharing_service=object(), + ) + + server_composition.reset_server_services() + server_composition.reset_server_services() + + with pytest.raises(RuntimeError): + server_provider.MoviePilotServerHelper._report_service() + with pytest.raises(RuntimeError): + server_provider.MoviePilotServerHelper._sharing_service() + + +def test_chain_owner_reset_is_symmetric_and_idempotent(monkeypatch) -> None: + """Chain owner 撤销无参上下文,并把壁纸来源恢复为空结果。""" + monkeypatch.setattr( + chain_provider, + "_context_provider", + chain_provider._unconfigured_chain_runtime_context, + ) + chain_provider.configure_chain_runtime_context_provider(lambda: object()) + image_provider.configure_wallpaper_providers( + tmdb_wallpaper=lambda: "tmdb", + tmdb_wallpapers=lambda _count: ["tmdb"], + mediaserver_wallpaper=lambda: "media", + mediaserver_wallpapers=lambda _count: ["media"], + ) + + chain_composition.reset_chain_services() + chain_composition.reset_chain_services() + + with pytest.raises(RuntimeError, match="Chain 运行上下文尚未由启动组合根配置"): + chain_provider.get_chain_runtime_context() + wallpaper = image_provider.WallpaperHelper() + assert wallpaper.get_tmdb_wallpaper() is None + assert wallpaper.get_tmdb_wallpapers() == [] + assert wallpaper.get_mediaserver_wallpaper() is None + assert wallpaper.get_mediaserver_wallpapers() == [] + + +def test_composition_owner_resets_run_in_reverse_publication_order(monkeypatch) -> None: + """多 provider owner 必须按发布逆序撤销,避免依赖先于消费者消失。""" + calls: list[str] = [] + + for module, names in ( + ( + agent_composition, + ( + "reset_agent_task_execution", + "reset_agent_chat_persistence", + "reset_agent_chat_service", + ), + ), + ( + security_composition, + ( + "reset_passkey_service", + "reset_passkey_challenge_cache", + "reset_auth_service", + "reset_user_lookups", + ), + ), + ( + network_composition, + ( + "reset_message_ingress_port", + "reset_image_ports", + "reset_network_test_service", + ), + ), + ( + chain_composition, + ( + "configure_chain_runtime_context_provider", + "reset_wallpaper_providers", + ), + ), + ): + for name in names: + monkeypatch.setattr( + module, + name, + lambda *args, _name=name, **kwargs: calls.append(_name), + ) + + agent_composition.reset_agent_services( + data_context_resetter=lambda: calls.append("reset_agent_data_context"), + ) + security_composition.reset_security_services() + network_composition.reset_application_network_ports() + chain_composition.reset_chain_services() + + assert calls == [ + "reset_agent_data_context", + "reset_agent_task_execution", + "reset_agent_chat_persistence", + "reset_agent_chat_service", + "reset_passkey_service", + "reset_passkey_challenge_cache", + "reset_auth_service", + "reset_user_lookups", + "reset_message_ingress_port", + "reset_image_ports", + "reset_network_test_service", + "configure_chain_runtime_context_provider", + "reset_wallpaper_providers", + ]