Files
MoviePilot/tests/test_plugin_static.py

102 lines
4.4 KiB
Python

"""
验证插件静态资源的浏览器缓存、条件请求和访问安全边界。
"""
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from fastapi import FastAPI, HTTPException
from starlette.testclient import TestClient
from app.api.endpoints import plugin
@pytest.fixture
def static_files(tmp_path, monkeypatch):
"""
在临时插件目录与独立路由中提供资源,保留真实访问校验依赖。
"""
root = tmp_path / "app/plugins/demo/dist"
root.mkdir(parents=True)
(root / "remoteEntry.js").write_text("export const version = 1", encoding="utf-8")
(root / "chunk.js").write_text("export const value = 1", encoding="utf-8")
manager = Mock()
manager.get_plugin_source_id.return_value = "Demo"
manager.get_plugin_ids.return_value = ["Demo"]
manager.get_plugin_attr.return_value = "1.0.0"
manager.get_plugin_auth_providers.return_value = []
verify = Mock()
monkeypatch.setattr(plugin, "get_plugin_manager", lambda: manager)
monkeypatch.setattr(plugin, "verify_resource_token", verify)
monkeypatch.setattr(plugin, "get_api_runtime_config_snapshot", lambda: SimpleNamespace(root_path=tmp_path))
app = FastAPI()
app.include_router(plugin.router, prefix="/plugin")
return TestClient(app), root, manager, verify
def test_current_version_entry_is_private_and_cacheable(static_files):
"""
只有当前版本参数可授予长期私有缓存,且返回文件校验头。
"""
client, _, manager, _ = static_files
response = client.get("/plugin/file/demo/dist/remoteEntry.js?v=1.0.0")
assert response.status_code == 200
assert response.content == b"export const version = 1"
assert response.headers["content-type"].startswith("application/javascript")
assert response.headers["cache-control"] == "private, max-age=31536000, immutable"
assert response.headers["etag"]
assert response.headers["last-modified"]
manager.get_plugin_attr.assert_called_once_with("Demo", "plugin_version")
manager.get_plugin_attr.return_value = "2.0.0"
assert client.get("/plugin/file/Demo/dist/remoteEntry.js?v=1.0.0").headers["cache-control"] == "private, no-cache"
assert "immutable" in client.get("/plugin/file/Demo/dist/remoteEntry.js?v=2.0.0").headers["cache-control"]
@pytest.mark.parametrize("validator", ["etag", "last-modified"])
def test_unversioned_chunks_revalidate_without_resending_body(static_files, validator):
"""
无版本 chunk 保留缓存内容,条件命中时仍校验权限并返回无响应体的 304。
"""
client, _, _, verify = static_files
url = "/plugin/file/Demo/dist/chunk.js"
first = client.get(url)
header = "If-None-Match" if validator == "etag" else "If-Modified-Since"
response = client.get(url, headers={header: first.headers[validator]})
assert response.status_code == 304
assert response.content == b""
assert response.headers["cache-control"] == "private, no-cache"
assert response.headers[validator] == first.headers[validator]
assert verify.call_count == 2
verify.side_effect = HTTPException(status_code=403, detail="Forbidden")
assert client.get(url, headers={header: first.headers[validator]}).status_code == 403
def test_changed_file_and_nonmatching_etag_return_new_body(static_files):
"""
文件改变或 ETag 不匹配时,不被旧修改时间错误判为未修改。
"""
client, root, _, _ = static_files
url = "/plugin/file/Demo/dist/chunk.js"
first = client.get(url)
(root / "chunk.js").write_text("export const value = 'updated'", encoding="utf-8")
response = client.get(url, headers={
"If-None-Match": first.headers["etag"],
"If-Modified-Since": "Wed, 01 Jan 2099 00:00:00 GMT",
})
assert response.status_code == 200
assert response.headers["etag"] != first.headers["etag"]
assert response.content == b"export const value = 'updated'"
def test_missing_and_escaped_files_are_not_cacheable(static_files, tmp_path):
"""
缺失资源与指向插件外的符号链接保持原有拒绝语义。
"""
client, root, _, _ = static_files
outside = tmp_path / "outside.js"
outside.write_text("private", encoding="utf-8")
(root / "escaped.js").symlink_to(outside)
assert client.get("/plugin/file/Demo/dist/escaped.js").status_code == 403
assert client.get("/plugin/file/Demo/dist/missing.js?v=1.0.0").status_code == 404