From b4175a4f88421cf3515eeb651b8a4febe836642f Mon Sep 17 00:00:00 2001 From: jxxghp Date: Wed, 30 Sep 2026 01:09:32 +0800 Subject: [PATCH] fix(image): cache site allowlist and include custom image hosts --- app/api/endpoints/system.py | 37 +++---- app/application/security/image.py | 84 ++++++++++++++ app/application/site/mutation.py | 2 + app/db/adapters/site.py | 13 ++- docs/architecture-overview.md | 6 +- docs/mcp-api.md | 7 +- docs/refactor/optimization-checklist.md | 2 +- .../architecture/dependency-baseline.json | 15 ++- .../startup-performance-baseline.json | 6 +- tests/test_security_image_domains.py | 103 ++++++++++++++++++ tests/test_site_mutation_command.py | 27 +++++ tests/test_site_repository.py | 27 +++++ tests/test_system_image_proxy.py | 28 ++++- 13 files changed, 318 insertions(+), 39 deletions(-) create mode 100644 app/application/security/image.py create mode 100644 tests/test_security_image_domains.py diff --git a/app/api/endpoints/system.py b/app/api/endpoints/system.py index 0eed1cac5..493b4cae0 100644 --- a/app/api/endpoints/system.py +++ b/app/api/endpoints/system.py @@ -43,6 +43,7 @@ from app.application.messaging.message import MessageHelper from app.application.network import get_configured_network_test_service from app.application.rules import RuleHelper from app.application.scheduling import get_scheduler +from app.application.security.image import normalize_image_domain, site_image_domains from app.application.security.url import SecurityUtils from app.application.site.query import get_configured_site_query_service from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module @@ -109,32 +110,20 @@ _PUBLIC_SETTINGS_KEYS = {"PLUGIN_MARKET"} async def _get_image_proxy_allowed_domains() -> set[str]: - """合并静态白名单、已配置站点和 Bangumi 图片主机,仍保留 DNS 安全校验。""" + """合并站点快照与当前图片代理配置,仍逐请求执行 DNS 安全校验。""" runtime_settings = get_runtime_settings() allowed_domains = set(runtime_settings.get("SECURITY_IMAGE_DOMAINS", [])) - # 停用站点仍需展示图标;每次读取配置,新增、改址和删除立即生效。 - for site in await get_configured_site_query_service().list(): - for address in (site.domain, site.url): - if not address: - continue - candidate = address if "://" in address else f"https://{address}" - try: - parsed = urlsplit(candidate) - except ValueError: - continue - if parsed.scheme in {"http", "https"} and parsed.netloc and not parsed.username: - # 保留协议,避免白名单解析器把 host:port 的主机误认成 scheme。 - allowed_domains.add(f"{parsed.scheme}://{parsed.netloc.lower()}") - if not runtime_settings.get("BANGUMI_PROXY_ENABLE"): - return allowed_domains - - image_proxy = str(runtime_settings.get("BANGUMI_IMAGE_DOMAIN") or "").strip() - if not image_proxy: - return allowed_domains - candidate = image_proxy if "://" in image_proxy else f"https://{image_proxy}" - parsed = urlsplit(candidate) - if parsed.scheme in {"http", "https"} and parsed.netloc: - allowed_domains.add(parsed.netloc) + allowed_domains.update(await site_image_domains.get(get_configured_site_query_service())) + image_keys = [ + "TMDB_IMAGE_DOMAIN", "MUSIC_COVER_PROXY", + "WALLPAPER_IMAGE_URL", "CUSTOMIZE_WALLPAPER_API_URL", + ] + if runtime_settings.get("BANGUMI_PROXY_ENABLE"): + image_keys.append("BANGUMI_IMAGE_DOMAIN") + for key in image_keys: + domain = normalize_image_domain(runtime_settings.get(key)) + if domain: + allowed_domains.add(domain) return allowed_domains diff --git a/app/application/security/image.py b/app/application/security/image.py new file mode 100644 index 000000000..7b3662d58 --- /dev/null +++ b/app/application/security/image.py @@ -0,0 +1,84 @@ +"""图片域名归一化与站点白名单快照,不缓存 DNS 安全判定。""" + +import asyncio +import threading +from time import monotonic +from typing import Optional +from urllib.parse import urlsplit +from weakref import WeakValueDictionary + +from app.application.site.query import SiteQueryService + + +def normalize_image_domain(address: Optional[str]) -> Optional[str]: + """提取 HTTP(S) 主机及端口,保留协议以兼容 URL 白名单解析。""" + address = (address or "").strip() + if not address: + return None + try: + parsed = urlsplit(address if "://" in address else f"https://{address}") + if parsed.scheme in {"http", "https"} and parsed.hostname and parsed.username is None: + return f"{parsed.scheme}://{parsed.netloc.lower()}" + except ValueError: + return None + return None + + +class SiteImageDomainCache: + """缓存站点图片域名,同 loop 合并查询,写入失效后禁止旧查询回填。""" + + def __init__(self, ttl: float = 60) -> None: + """设置短期兜底有效期;锁只保护内存,不跨 await 持有线程锁。""" + self._ttl = ttl + self._lock = threading.Lock() + self._inflight: WeakValueDictionary[asyncio.AbstractEventLoop, asyncio.Lock] = WeakValueDictionary() + self._generation = 0 + self._source: Optional[SiteQueryService] = None + self._domains: frozenset[str] = frozenset() + self._expires = 0.0 + + def invalidate(self) -> None: + """提交后立即撤销旧域名;递增代际防止在途查询恢复已删除的授权。""" + with self._lock: + self._generation += 1 + self._expires = 0.0 + self._source = None + self._domains = frozenset() + + async def get(self, source: SiteQueryService) -> frozenset[str]: + """返回不可变快照,命中时不查库;配置服务更换后自动重建。""" + loop = asyncio.get_running_loop() + with self._lock: + if self._source is source and monotonic() < self._expires: + return self._domains + inflight = self._inflight.get(loop) + if inflight is None: + inflight = asyncio.Lock() + self._inflight[loop] = inflight + async with inflight: + return await self._refresh(source) + + async def _refresh(self, source: SiteQueryService) -> frozenset[str]: + """锁内复查并加载,失效与查询交错时丢弃旧结果后重新读取。""" + while True: + with self._lock: + if self._source is source and monotonic() < self._expires: + return self._domains + generation = self._generation + sites = await source.list() + domains = frozenset( + domain + for site in sites + for address in (site.domain, site.url) + if (domain := normalize_image_domain(address)) + ) + with self._lock: + if generation != self._generation: + continue + self._source = source + self._domains = domains + self._expires = monotonic() + self._ttl + return domains + + +site_image_domains = SiteImageDomainCache() diff --git a/app/application/site/mutation.py b/app/application/site/mutation.py index 1ef0f91b9..63cf621f8 100644 --- a/app/application/site/mutation.py +++ b/app/application/site/mutation.py @@ -7,6 +7,7 @@ from typing import Optional, TypeAlias from app.application.configuration import SystemConfigStagingPort from app.application.outbox import SyncUnitOfWork +from app.application.security.image import site_image_domains from app.application.site.contract import ( SiteMutation, SitePriorityMutation, @@ -252,3 +253,4 @@ class SiteMutationCommand: except Exception: await self._unit_of_work.rollback() raise + site_image_domains.invalidate() diff --git a/app/db/adapters/site.py b/app/db/adapters/site.py index fd0b597ef..3a1951b3b 100644 --- a/app/db/adapters/site.py +++ b/app/db/adapters/site.py @@ -10,6 +10,7 @@ from typing import Optional, TypeVar, cast from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session +from app.application.security.image import site_image_domains from app.application.site.contract import ( SiteIconSnapshot, SiteMutation, @@ -424,7 +425,9 @@ class TransactionalSiteRepository: def add(self, mutation: SiteMutation) -> SiteWriteResult: """在独立同步事务中新增站点。""" - return self._write(lambda repository: SiteWriteResult(*repository.add(**mutation.to_payload()))) + result = self._write(lambda repository: SiteWriteResult(*repository.add(**mutation.to_payload()))) + site_image_domains.invalidate() + return result def update( self, @@ -432,13 +435,15 @@ class TransactionalSiteRepository: mutation: SiteMutation, ) -> Optional[SiteSnapshot]: """在独立同步事务中更新并返回站点快照。""" - return self._write( + result = self._write( lambda repository: ( _project_site(record) if (record := repository.update(site_id, mutation.to_payload())) is not None else None ) ) + site_image_domains.invalidate() + return result async def async_update( self, @@ -455,7 +460,9 @@ class TransactionalSiteRepository: ) return _project_site(record) if record is not None else None - return await self._async_write(operation) + result = await self._async_write(operation) + site_image_domains.invalidate() + return result def update_cookie(self, domain: str, cookies: str) -> SiteWriteResult: """在独立同步事务中更新站点 Cookie。""" diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index e3d052b0e..ffbbe225f 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -762,8 +762,8 @@ flowchart LR | 指标 | 当前值 | |---|---:| -| Python 模块 | 1052 | -| 内部导入边 | 8,928 | +| Python 模块 | 1053 | +| 内部导入边 | 8,937 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 53(债务已清零,53 条精确 containment) | @@ -773,7 +773,7 @@ flowchart LR | Model/Oper 自动事务与自建 Session | 0 | | 组合根外 `SystemConfigOper()` | 0 | -整理失败反馈由 `app.application.transfer.feedback` 集中投影;已整理下载的站点字幕复用 `app.chain.transfer` 自动整理队列;网络连通性检测按 `app.application.nettest` 归档,并覆盖 HTTP 与 WebSocket 探测。未启用智能助手的消息音乐交互由 `app.chain.music_interaction` 包中的解析展示与订阅协调模块处理;宿主依赖边现为 8,928,音乐交互包未新增 SCC,插件一次性任务 owner `app.scheduler.oncejob` 带来 1 个模块和 9 条依赖边。Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。缓存组合根只在启动时选用 Redis 缓存才导入 Redis 适配器,依赖 langchain_core 的对话记忆模型归属 `app.agent.memory`,文件缓存且未启用智能体的实例不再加载 redis 与 langchain_core。当前 `app.startup.lifecycle` 为 572、`app.factory` 为 584、`app.main` 为 586。性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。 +整理失败反馈由 `app.application.transfer.feedback` 集中投影;已整理下载的站点字幕复用 `app.chain.transfer` 自动整理队列;网络连通性检测按 `app.application.nettest` 归档,并覆盖 HTTP 与 WebSocket 探测。未启用智能助手的消息音乐交互由 `app.chain.music_interaction` 包中的解析展示与订阅协调模块处理;宿主依赖边现为 8,937,音乐交互包未新增 SCC,插件一次性任务 owner `app.scheduler.oncejob` 带来 1 个模块和 9 条依赖边。Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。缓存组合根只在启动时选用 Redis 缓存才导入 Redis 适配器,依赖 langchain_core 的对话记忆模型归属 `app.agent.memory`,文件缓存且未启用智能体的实例不再加载 redis 与 langchain_core。当前 `app.startup.lifecycle` 为 573、`app.factory` 为 585、`app.main` 为 587。站点图片域名快照新增 `app.application.security.image`,各启动入口增加一个模块;性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。 架构专项验证分为两个 CI 投影:`Check event semantic policy` 先运行依赖、Adapter、出口和 Event 语义门禁,`Check host architecture snapshot` 再执行快照测试及一次 diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 12f83d595..3d3001331 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -23,7 +23,12 @@ MCP 使用系统配置中的 `API_TOKEN` 作为认证密钥,文档中的 API K 图片 REST 接口 `/api/v1/system/img/{proxy}` 和 `/api/v1/system/cache/image` 自动将已配置 站点的主域名及访问地址加入图片域名白名单(含停用站点,便于展示图标),无需手动配置 -`SECURITY_IMAGE_DOMAINS`。站点新增、改址或删除后按最新配置生效;仍执行 DNS/私网校验, +`SECURITY_IMAGE_DOMAINS`。站点域名快照在内存中复用,同一事件循环的并发加载合并为一次查询; +站点新增、改址或删除提交后立即失效,另有 60 秒过期兜底。`TMDB_IMAGE_DOMAIN`、 +`MUSIC_COVER_PROXY`、`WALLPAPER_IMAGE_URL`、`CUSTOMIZE_WALLPAPER_API_URL` 和启用时的 +`BANGUMI_IMAGE_DOMAIN` 的 HTTP(S) 主机也自动放行,配置变更即时生效。壁纸 API 返回的其他 +域名不自动扩展授权;必要时加入 `SECURITY_IMAGE_DOMAINS`。 +域名放行后仍执行 DNS/私网校验, Fake-IP 等非公网解析地址需要通过 `IMAGE_PROXY_ALLOWED_PRIVATE_RANGES` 明确允许。 ## 2. 标准 MCP 协议 (JSON-RPC 2.0) diff --git a/docs/refactor/optimization-checklist.md b/docs/refactor/optimization-checklist.md index be3b7dd92..c892ec59d 100644 --- a/docs/refactor/optimization-checklist.md +++ b/docs/refactor/optimization-checklist.md @@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 1052 / 8,928 | `dependency-baseline.json` 当前快照;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档;消息音乐交互由 `app.chain.music_interaction` 包中的音乐解析/展示与订阅协调模块处理;已整理下载的站点字幕经 `app.chain.transfer` 自动整理队列完成命名与归档;网络连通性检测按 `app.application.nettest` 归档 | +| 宿主 Python 模块 / 内部依赖边 | 1053 / 8,937 | `dependency-baseline.json` 当前快照;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档;消息音乐交互由 `app.chain.music_interaction` 包中的音乐解析/展示与订阅协调模块处理;已整理下载的站点字幕经 `app.chain.transfer` 自动整理队列完成命名与归档;网络连通性检测按 `app.application.nettest` 归档 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index c36258c40..0e4476e2b 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1097,8 +1097,8 @@ "runtime_only": true } }, - "edge_count": 8929, - "edge_sha256": "568144c98e697e2e7f27f672c1a65afb3a947631957df79786bf700a4abdeeae", + "edge_count": 8937, + "edge_sha256": "971c665cd7d8ca534b7a6f09ce61d0c9e7cf4ba00b3340dd60840c1fe924af8b", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -3056,6 +3056,7 @@ "app.api.endpoints.system -> app.application.rules", "app.api.endpoints.system -> app.application.scheduling", "app.api.endpoints.system -> app.application.security", + "app.api.endpoints.system -> app.application.security.image", "app.api.endpoints.system -> app.application.security.url", "app.api.endpoints.system -> app.application.site", "app.api.endpoints.system -> app.application.site.query", @@ -3908,6 +3909,9 @@ "app.application.security.cookie -> app.domain.site", "app.application.security.cookie -> app.runtime", "app.application.security.cookie -> app.runtime.log", + "app.application.security.image -> app.application", + "app.application.security.image -> app.application.site", + "app.application.security.image -> app.application.site.query", "app.application.security.passkey -> app.application", "app.application.security.passkey -> app.application.configuration", "app.application.security.passkey -> app.runtime", @@ -3983,6 +3987,8 @@ "app.application.site.mutation -> app.application", "app.application.site.mutation -> app.application.configuration", "app.application.site.mutation -> app.application.outbox", + "app.application.site.mutation -> app.application.security", + "app.application.site.mutation -> app.application.security.image", "app.application.site.mutation -> app.application.site", "app.application.site.mutation -> app.application.site.contract", "app.application.site.mutation -> app.application.subscription", @@ -5970,6 +5976,8 @@ "app.db.adapters.query -> app.schemas", "app.db.adapters.query -> app.schemas.query", "app.db.adapters.site -> app.application", + "app.db.adapters.site -> app.application.security", + "app.db.adapters.site -> app.application.security.image", "app.db.adapters.site -> app.application.site", "app.db.adapters.site -> app.application.site.contract", "app.db.adapters.site -> app.db", @@ -10030,7 +10038,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 1052, + "module_count": 1053, "modules": [ "app", "app.adapters", @@ -10356,6 +10364,7 @@ "app.application.security", "app.application.security.auth", "app.application.security.cookie", + "app.application.security.image", "app.application.security.otp", "app.application.security.passkey", "app.application.security.secrets", diff --git a/tests/fixtures/architecture/startup-performance-baseline.json b/tests/fixtures/architecture/startup-performance-baseline.json index b12ba18ad..3cf9ea7f2 100644 --- a/tests/fixtures/architecture/startup-performance-baseline.json +++ b/tests/fixtures/architecture/startup-performance-baseline.json @@ -6,7 +6,7 @@ "repeat": 3, "targets": { "app.startup.lifecycle": { - "loaded_app_module_count": 572, + "loaded_app_module_count": 573, "max_ms": 1293.338, "median_ms": 1156.239, "min_ms": 1102.806, @@ -17,7 +17,7 @@ ] }, "app.factory": { - "loaded_app_module_count": 584, + "loaded_app_module_count": 585, "max_ms": 1127.911, "median_ms": 1122.382, "min_ms": 1119.221, @@ -28,7 +28,7 @@ ] }, "app.main": { - "loaded_app_module_count": 586, + "loaded_app_module_count": 587, "max_ms": 1188.652, "median_ms": 1183.509, "min_ms": 1174.522, diff --git a/tests/test_security_image_domains.py b/tests/test_security_image_domains.py new file mode 100644 index 000000000..667c876d6 --- /dev/null +++ b/tests/test_security_image_domains.py @@ -0,0 +1,103 @@ +"""图片域名快照的并发、失效和过期回归。""" + +import asyncio +from unittest.mock import AsyncMock + +import pytest + +from app.application.security import image as image_policy +from app.application.security.image import SiteImageDomainCache, normalize_image_domain +from app.schemas.site import Site + + +@pytest.mark.parametrize( + ("address", "expected"), + [ + (" CDN.Example:8443/path ", "https://cdn.example:8443"), + ("https://[2001:4860:4860::8888]:8443/path", "https://[2001:4860:4860::8888]:8443"), + ("https://user@host/path", None), + ("https://@host/path", None), + ("https://[", None), + ("file://host/path", None), + (None, None), + ], +) +def test_normalize_image_domain(address: str | None, expected: str | None) -> None: + """只归一化有效图片主机,拒绝用户信息和非 HTTP 协议。""" + assert normalize_image_domain(address) == expected + + +@pytest.mark.asyncio +async def test_concurrent_image_requests_share_one_site_query() -> None: + """冷启动并发和后续命中均共享一次数据库查询,空结果也缓存。""" + cache = SiteImageDomainCache() + source = AsyncMock() + entered = asyncio.Event() + release = asyncio.Event() + + async def load() -> list[Site]: + """挂起查询以模拟同一页面并发加载图片。""" + entered.set() + await release.wait() + return [] + + source.list.side_effect = load + tasks = [asyncio.create_task(cache.get(source)) for _ in range(30)] + await entered.wait() + release.set() + assert await asyncio.gather(*tasks) == [frozenset()] * 30 + assert await cache.get(source) == frozenset() + assert source.list.await_count == 1 + + +@pytest.mark.asyncio +async def test_invalidated_inflight_query_cannot_restore_old_domain() -> None: + """删除或改址发生在查询途中时,旧结果不能重新写入安全白名单。""" + cache = SiteImageDomainCache() + source = AsyncMock() + + async def load() -> list[Site]: + """第一次查询模拟读取旧快照后发生提交失效。""" + if source.list.await_count == 1: + cache.invalidate() + return [Site(url="https://old.example/")] + return [Site(url="https://new.example/")] + + source.list.side_effect = load + assert await cache.get(source) == frozenset({"https://new.example"}) + assert source.list.await_count == 2 + assert await cache.get(source) == frozenset({"https://new.example"}) + assert source.list.await_count == 2 + + +@pytest.mark.asyncio +async def test_expiry_and_source_replacement_refresh_snapshot(monkeypatch: pytest.MonkeyPatch) -> None: + """短期过期兜底及 lifespan 查询服务更换均重新加载站点。""" + clock = [100.0] + monkeypatch.setattr(image_policy, "monotonic", lambda: clock[0]) + cache = SiteImageDomainCache(ttl=60) + source = AsyncMock() + source.list.return_value = [Site(domain="first.example")] + assert await cache.get(source) == frozenset({"https://first.example"}) + clock[0] = 159 + await cache.get(source) + assert source.list.await_count == 1 + clock[0] = 160 + source.list.return_value = [] + assert await cache.get(source) == frozenset() + assert source.list.await_count == 2 + replacement = AsyncMock() + replacement.list.return_value = [Site(domain="second.example")] + assert await cache.get(replacement) == frozenset({"https://second.example"}) + + +@pytest.mark.asyncio +async def test_failed_query_is_retried() -> None: + """查询失败不缓存结果,恢复后重新获取配置。""" + cache = SiteImageDomainCache() + source = AsyncMock() + source.list.side_effect = [RuntimeError("unavailable"), []] + with pytest.raises(RuntimeError, match="unavailable"): + await cache.get(source) + assert await cache.get(source) == frozenset() + assert source.list.await_count == 2 diff --git a/tests/test_site_mutation_command.py b/tests/test_site_mutation_command.py index 4be862202..219586d1d 100644 --- a/tests/test_site_mutation_command.py +++ b/tests/test_site_mutation_command.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, Mock import pytest +from app.application.site import mutation as site_mutation from app.application.site.contract import SiteMutation, SitePriorityMutation from app.application.site.mutation import SiteMutationCommand from app.schemas.site import Site @@ -58,6 +59,32 @@ async def test_create_site_commits_before_updated_event(): assert mutation.values["public"] == 1 +@pytest.mark.asyncio +@pytest.mark.parametrize("commit_fails", [False, True]) +async def test_site_commit_invalidates_images_only_after_success(monkeypatch, commit_fails): + """图片授权只在提交成功后失效,并先于站点事件发布。""" + calls = [] + monkeypatch.setattr(site_mutation.site_image_domains, "invalidate", lambda: calls.append("invalidate")) + + async def commit(): + """模拟提交成功或失败,记录真实调用顺序。""" + calls.append("commit") + if commit_fails: + raise RuntimeError("commit failed") + + command, _ = _command( + unit_of_work=Mock(commit=AsyncMock(side_effect=commit), rollback=AsyncMock()), + publish_deleted=AsyncMock(side_effect=lambda _payload: calls.append("event")), + ) + if commit_fails: + with pytest.raises(RuntimeError, match="commit failed"): + await command.delete(1) + assert calls == ["commit"] + else: + await command.delete(1) + assert calls == ["commit", "invalidate", "event"] + + @pytest.mark.asyncio async def test_create_site_ignores_schema_identity_field(): """手动新增站点应忽略请求 Schema 自动补出的空主键。""" diff --git a/tests/test_site_repository.py b/tests/test_site_repository.py index 403f94f17..a7d1848f1 100644 --- a/tests/test_site_repository.py +++ b/tests/test_site_repository.py @@ -2,6 +2,7 @@ import pytest +from app.application.security.image import SiteImageDomainCache from app.application.site.contract import ( SiteMutation, SitePriorityMutation, @@ -10,6 +11,7 @@ from app.application.site.contract import ( SiteUserDataSnapshot, ) from app.application.site.query import SiteQueryService +from app.db.adapters import site as site_adapter from app.db.adapters.site import SessionSiteRepository, TransactionalSiteRepository from app.db.models.site import Site from app.db.models.siteicon import SiteIcon @@ -27,6 +29,31 @@ def _transactional_repository() -> TransactionalSiteRepository: ) +@pytest.mark.asyncio +async def test_site_writes_refresh_image_domains(db, monkeypatch) -> None: + """同步新增、同步改址和异步改址提交后均撤销旧图片域名快照。""" + db.watermark(Site) + cache = SiteImageDomainCache() + monkeypatch.setattr(site_adapter, "site_image_domains", cache) + repository = _transactional_repository() + source = SiteQueryService(repository=repository) + original = await cache.get(source) + assert repository.add(SiteMutation({ + "name": "图片缓存", "domain": "image-cache.example", "url": "https://old-image.example/", + })).success + snapshot = repository.get_by_domain("image-cache.example") + assert snapshot is not None + assert "https://old-image.example" in await cache.get(source) - original + repository.update(snapshot.id, SiteMutation({"url": "https://new-image.example/"})) + domains = await cache.get(source) + assert "https://old-image.example" not in domains + assert "https://new-image.example" in domains + await repository.async_update(snapshot.id, SiteMutation({"url": "https://async-image.example/"})) + domains = await cache.get(source) + assert "https://new-image.example" not in domains + assert "https://async-image.example" in domains + + def test_transactional_repository_projects_frozen_site_snapshot(db) -> None: """独立仓储应在 Session 内复制 JSON 并返回不可变站点快照。""" db.watermark(Site) diff --git a/tests/test_system_image_proxy.py b/tests/test_system_image_proxy.py index 9f2d93f7d..be3cc9631 100644 --- a/tests/test_system_image_proxy.py +++ b/tests/test_system_image_proxy.py @@ -18,6 +18,7 @@ from app.application.image import ( configure_image_ports, reset_image_ports, ) +from app.application.security.image import SiteImageDomainCache from app.schemas.site import Site @@ -26,6 +27,7 @@ def configured_image_sites(monkeypatch: pytest.MonkeyPatch) -> AsyncMock: """隔离图片白名单的站点查询,避免其他用例的数据库状态影响结果。""" query = AsyncMock() query.list.return_value = [] + monkeypatch.setattr(system_endpoint, "site_image_domains", SiteImageDomainCache()) monkeypatch.setattr(system_endpoint, "get_configured_site_query_service", lambda: query) return query @@ -86,9 +88,11 @@ def test_site_image_allowlist_tracks_configuration(configured_image_sites: Async assert asyncio.run(system_endpoint._get_image_proxy_allowed_domains()) == { "https://qingwapt.com", "https://old.example", } + system_endpoint.site_image_domains.invalidate() assert asyncio.run(system_endpoint._get_image_proxy_allowed_domains()) == { "https://qingwapt.com", "https://new.example", } + system_endpoint.site_image_domains.invalidate() assert asyncio.run(system_endpoint._get_image_proxy_allowed_domains()) == set() @@ -173,10 +177,32 @@ def test_bangumi_image_proxy_domain_is_added_to_allowlist() -> None: ): assert asyncio.run(system_endpoint._get_image_proxy_allowed_domains()) == { "lain.bgm.tv", - "image-proxy.example", + "https://image-proxy.example", } +@pytest.mark.parametrize("key", [ + "TMDB_IMAGE_DOMAIN", "MUSIC_COVER_PROXY", "BANGUMI_IMAGE_DOMAIN", + "WALLPAPER_IMAGE_URL", "CUSTOMIZE_WALLPAPER_API_URL", +]) +@pytest.mark.parametrize("enabled", [False, True]) +def test_custom_image_proxy_settings_are_live( + configured_image_sites: AsyncMock, key: str, enabled: bool, +) -> None: + """图片代理配置即时更新;Bangumi 关闭时不放行其自定义代理。""" + settings = {key: "https://proxy.example:8443/base/?url=", "BANGUMI_PROXY_ENABLE": enabled} + with patch.object(system_endpoint, "get_runtime_settings", return_value=settings): + expected = key != "BANGUMI_IMAGE_DOMAIN" or enabled + assert ("https://proxy.example:8443" in asyncio.run( + system_endpoint._get_image_proxy_allowed_domains(), + )) is expected + settings[key] = "https://new.example/path/" + domains = asyncio.run(system_endpoint._get_image_proxy_allowed_domains()) + assert "https://proxy.example:8443" not in domains + assert ("https://new.example" in domains) is expected + assert configured_image_sites.list.await_count == 1 + + def test_image_proxy_trusts_enabled_mediaserver_hosts() -> None: """已启用媒体服务器的 host / play_host 应被规范化为受信主机。""" confs = [