Files

484 lines
19 KiB
Python
Raw Permalink Normal View History

"""SVFSStaticFiles(FastAPI / Starlette 静态文件插件)的集成测试。
没有安装 fastapi / starlette / httpx 时整个模块会被跳过,
这样"插件是可选依赖"的约束在测试层面也成立 —— 核心包不因为它的测试而需要装框架。
用真实 ASGI 栈(FastAPI + TestClient)跑请求,而不是直接调内部方法,
这样 405 / 重定向 / 条件请求 / Range 这些行为都按 HTTP 语义被验证。
"""
import hashlib
import os
import struct
import unittest
import helpers
try:
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlite_vfs.contrib.fastapi_static import SVFSStaticFiles
AVAILABLE = True
IMPORT_ERROR = None
except ImportError as exc: # pragma: no cover
AVAILABLE = False
IMPORT_ERROR = exc
#: 资源包内容:覆盖 MIME 覆盖表、可压缩大文件、空文件、前端壳、嵌套目录
ASSETS = {
"index.html": b"<!doctype html><title>app</title><div id=app>shell",
"app.js": b"console.log('hi');",
"app.mjs": b"export default 1;",
"style.css": b"body{margin:0}",
"font.woff2": b"\x77\x4f\x46\x32\x00\x01",
"data.json": b'{"ok":true}',
"empty.txt": b"",
"big.txt": b"0123456789abcdef" * 2000, # 32KB,会被哈夫曼压缩
"sub/page.html": b"<p>sub page</p>",
"sub/index.html": b"<p>sub index</p>",
"assets/logo.png": b"\x89PNG\r\n\x1a\n",
}
@unittest.skipUnless(AVAILABLE, f"需要 fastapi/starlette/httpx: {IMPORT_ERROR}")
class NeedsFastAPI(helpers.VFSTestCase):
"""缺少框架时整类跳过(子类通过属性继承同样被跳过)。"""
class PluginTestCase(NeedsFastAPI):
"""构造一个真实的 .svfs 资源包,并把它挂在 FastAPI 上当静态目录。"""
compress = True
algorithm = None # None = 用默认算法(zlib)
spa = True
html = True
def setUp(self):
super().setUp()
self.src = helpers.make_tree(self.path("dist"), ASSETS)
self.db = helpers.pack(
self.src,
self.path("static.svfs"),
compress=self.compress,
name="assets",
algorithm=self.algorithm,
)
self.md5_before = helpers.md5_of(self.db)
self.statics = SVFSStaticFiles(self.db, html=self.html, spa=self.spa)
self.addCleanup(self.statics.close)
app = FastAPI()
app.mount("/", self.statics, name="static")
self.client = TestClient(app, follow_redirects=False)
def get(self, path, **kwargs):
return self.client.get(path, headers=kwargs.pop("headers", None), **kwargs)
def assert_unmodified(self):
"""所有请求都必须是只读的,资源包不能被改动。"""
self.assertEqual(self.md5_before, helpers.md5_of(self.db))
class BasicServingTest(PluginTestCase):
def test_serves_top_level_file(self):
response = self.get("/app.js")
self.assertEqual(200, response.status_code)
self.assertEqual(ASSETS["app.js"], response.content)
self.assertEqual(b"console.log('hi');", response.content)
def test_serves_nested_file(self):
response = self.get("/sub/page.html")
self.assertEqual(200, response.status_code)
self.assertEqual(ASSETS["sub/page.html"], response.content)
def test_serves_empty_file(self):
response = self.get("/empty.txt")
self.assertEqual(200, response.status_code)
self.assertEqual(b"", response.content)
self.assertEqual("0", response.headers["content-length"])
def test_serves_compressed_file_correctly(self):
"""大文件在库里是哈夫曼压缩存储的,取出来必须还原成原始字节。"""
response = self.get("/big.txt")
self.assertEqual(200, response.status_code)
self.assertEqual(ASSETS["big.txt"], response.content)
def test_content_length_matches_body(self):
response = self.get("/big.txt")
self.assertEqual(str(len(ASSETS["big.txt"])), response.headers["content-length"])
class ContentTypeTest(PluginTestCase):
def test_web_types_are_not_platform_dependent(self):
"""Windows 注册表会把 .mjs 判成 text/plain(浏览器会拒绝执行模块),
这里断言插件用的是显式覆盖表。"""
expected = {
"/app.js": "text/javascript",
"/app.mjs": "text/javascript",
"/style.css": "text/css",
"/index.html": "text/html",
"/data.json": "application/json",
"/font.woff2": "font/woff2",
"/assets/logo.png": "image/png",
}
for path, content_type in expected.items():
with self.subTest(path=path):
response = self.get(path)
self.assertEqual(200, response.status_code)
self.assertEqual(
content_type, response.headers["content-type"].split(";")[0]
)
def test_unknown_extension_falls_back_to_octet_stream(self):
helpers.write_file(os.path.join(self.src, "blob.bin"), b"\x00\x01")
helpers.pack(self.src, self.db, compress=self.compress)
self.assertEqual(
"application/octet-stream",
self.get("/blob.bin").headers["content-type"].split(";")[0],
)
class IndexAndSpaTest(PluginTestCase):
def test_root_serves_index_html(self):
response = self.get("/")
self.assertEqual(200, response.status_code)
self.assertEqual(ASSETS["index.html"], response.content)
self.assertEqual("text/html", response.headers["content-type"].split(";")[0])
def test_directory_redirects_to_trailing_slash(self):
response = self.get("/sub")
self.assertEqual(307, response.status_code)
# Starlette 生成的是绝对 URL
self.assertTrue(
response.headers["location"].endswith("/sub/"),
response.headers["location"],
)
def test_directory_with_slash_serves_its_index(self):
response = self.get("/sub/")
self.assertEqual(200, response.status_code)
self.assertEqual(ASSETS["sub/index.html"], response.content)
def test_spa_fallback_for_frontend_route(self):
"""浏览器导航到前端路由 /settings 时应当拿到 index.html。"""
response = self.get("/settings", headers={"accept": "text/html"})
self.assertEqual(200, response.status_code)
self.assertEqual(ASSETS["index.html"], response.content)
def test_spa_fallback_for_deep_route(self):
response = self.get("/user/42/detail", headers={"accept": "text/html"})
self.assertEqual(200, response.status_code)
self.assertEqual(ASSETS["index.html"], response.content)
def test_no_fallback_for_missing_asset(self):
"""缺静态资源必须是 404,否则前端拿到一份 HTML 会报诡异的解析错误。"""
self.assertEqual(404, self.get("/missing.js").status_code)
self.assertEqual(404, self.get("/missing.css").status_code)
def test_no_fallback_for_json_request(self):
"""XHR/接口请求(Accept 不含 text/html)不该被回退成 index.html。"""
self.assertEqual(
404, self.get("/api/data", headers={"accept": "application/json"}).status_code
)
def test_missing_file_returns_404(self):
self.assertEqual(404, self.get("/nope.txt").status_code)
def test_post_is_rejected(self):
self.assertEqual(405, self.client.post("/app.js").status_code)
def test_path_traversal_cannot_escape_the_pack(self):
"""资源包不是磁盘目录,.. 之类无法越出数据库。"""
for path in ("/../../etc/passwd", "/..%2f..%2fsecret.txt", "/sub/../../app.js"):
with self.subTest(path=path):
self.assertIn(self.get(path).status_code, (200, 404, 307))
self.assert_unmodified()
def test_repeated_requests_do_not_modify_pack(self):
for path in ("/", "/app.js", "/big.txt", "/sub/", "/settings"):
self.get(path)
self.assert_unmodified()
class StrictModeTest(PluginTestCase):
"""html=False, spa=False 时行为与 StaticFiles 严格一致。"""
html = False
spa = False
def test_root_is_404(self):
self.assertEqual(404, self.get("/").status_code)
def test_route_is_404(self):
self.assertEqual(404, self.get("/settings", headers={"accept": "text/html"}).status_code)
def test_plain_file_still_served(self):
self.assertEqual(200, self.get("/app.js").status_code)
class ConditionalRequestTest(PluginTestCase):
def test_etag_and_last_modified_present(self):
response = self.get("/app.js")
self.assertIn("etag", response.headers)
self.assertIn("last-modified", response.headers)
self.assertEqual("bytes", response.headers["accept-ranges"])
def test_if_none_match_returns_304(self):
etag = self.get("/app.js").headers["etag"]
response = self.client.get("/app.js", headers={"if-none-match": etag})
self.assertEqual(304, response.status_code)
self.assertEqual(b"", response.content)
def test_if_none_match_with_weak_prefix(self):
etag = self.get("/app.js").headers["etag"]
response = self.client.get("/app.js", headers={"if-none-match": f"W/{etag}"})
self.assertEqual(304, response.status_code)
def test_if_modified_since_returns_304(self):
last_modified = self.get("/app.js").headers["last-modified"]
response = self.client.get(
"/app.js", headers={"if-modified-since": last_modified}
)
self.assertEqual(304, response.status_code)
def test_stale_if_none_match_returns_full_body(self):
response = self.client.get("/app.js", headers={"if-none-match": '"stale"'})
self.assertEqual(200, response.status_code)
self.assertEqual(ASSETS["app.js"], response.content)
def test_etag_is_stable_across_instances(self):
"""同一个资源包重新打开,ETag 不能变,否则缓存永远失效。"""
other = SVFSStaticFiles(self.db)
self.addCleanup(other.close)
app = FastAPI()
app.mount("/", other, name="static")
client = TestClient(app)
self.assertEqual(
self.get("/app.js").headers["etag"], client.get("/app.js").headers["etag"]
)
def test_max_age_sets_cache_control(self):
statics = SVFSStaticFiles(self.db, max_age=3600)
self.addCleanup(statics.close)
app = FastAPI()
app.mount("/", statics, name="static")
response = TestClient(app).get("/app.js")
self.assertEqual("public, max-age=3600", response.headers["cache-control"])
class HeadAndRangeTest(PluginTestCase):
def test_head_has_no_body_but_real_length(self):
response = self.client.head("/big.txt")
self.assertEqual(200, response.status_code)
self.assertEqual(b"", response.content)
self.assertEqual(str(len(ASSETS["big.txt"])), response.headers["content-length"])
def test_range_returns_206_with_slice(self):
response = self.client.get("/big.txt", headers={"range": "bytes=0-9"})
self.assertEqual(206, response.status_code)
self.assertEqual(ASSETS["big.txt"][:10], response.content)
self.assertEqual(
f"bytes 0-9/{len(ASSETS['big.txt'])}", response.headers["content-range"]
)
self.assertEqual("10", response.headers["content-length"])
def test_range_with_open_end(self):
response = self.client.get("/big.txt", headers={"range": "bytes=100-"})
self.assertEqual(206, response.status_code)
self.assertEqual(ASSETS["big.txt"][100:], response.content)
def test_suffix_range(self):
response = self.client.get("/big.txt", headers={"range": "bytes=-16"})
self.assertEqual(206, response.status_code)
self.assertEqual(ASSETS["big.txt"][-16:], response.content)
def test_range_on_compressed_content_is_correct(self):
"""库里存的是压缩数据,Range 必须作用在解压后的内容上。"""
for start, end in ((0, 0), (15, 31), (len(ASSETS["big.txt"]) - 5, 999999)):
with self.subTest(start=start, end=end):
response = self.client.get(
"/big.txt", headers={"range": f"bytes={start}-{end}"}
)
self.assertEqual(206, response.status_code)
self.assertEqual(ASSETS["big.txt"][start : end + 1], response.content)
def test_unsatisfiable_range_returns_416(self):
response = self.client.get(
"/big.txt", headers={"range": f"bytes={len(ASSETS['big.txt'])}-"}
)
self.assertEqual(416, response.status_code)
self.assertEqual(
f"bytes */{len(ASSETS['big.txt'])}", response.headers["content-range"]
)
def test_malformed_range_is_ignored(self):
"""语法错误的 Range 按 RFC 应当被忽略,返回整份内容。"""
for header in ("bytes=abc-def", "items=0-10", "", "bytes="):
with self.subTest(header=header):
response = self.client.get("/big.txt", headers={"range": header})
self.assertEqual(200, response.status_code)
self.assertEqual(ASSETS["big.txt"], response.content)
def test_multi_range_falls_back_to_full_body(self):
response = self.client.get("/big.txt", headers={"range": "bytes=0-1,4-5"})
self.assertEqual(200, response.status_code)
self.assertEqual(ASSETS["big.txt"], response.content)
def test_if_range_mismatch_ignores_range(self):
response = self.client.get(
"/big.txt", headers={"range": "bytes=0-9", "if-range": '"stale"'}
)
self.assertEqual(200, response.status_code)
self.assertEqual(ASSETS["big.txt"], response.content)
def test_if_range_match_uses_range(self):
etag = self.get("/big.txt").headers["etag"]
response = self.client.get(
"/big.txt", headers={"range": "bytes=0-9", "if-range": etag}
)
self.assertEqual(206, response.status_code)
def test_head_with_range(self):
response = self.client.head("/big.txt", headers={"range": "bytes=0-9"})
self.assertEqual(206, response.status_code)
self.assertEqual(b"", response.content)
self.assertEqual("10", response.headers["content-length"])
class CompressionAlgorithmServingTest(PluginTestCase):
"""资源包用哪种算法压缩,对使用方必须完全透明。"""
def test_pack_uses_zlib_by_default(self):
import sqlite3
conn = sqlite3.connect(f"file:{self.db}?mode=ro", uri=True)
try:
algorithms = {
row[0]
for row in conn.execute(
"""
SELECT b.compress_algo
FROM files f JOIN blobs b ON b.hash = f.content_hash
WHERE f.is_directory = 0
"""
)
}
finally:
conn.close()
self.assertIn("zlib", algorithms)
self.assertNotIn("huffman", algorithms)
def test_large_asset_survives_compressed_roundtrip(self):
response = self.get("/big.txt")
self.assertEqual(200, response.status_code)
self.assertEqual(ASSETS["big.txt"], response.content)
self.assertEqual(str(len(ASSETS["big.txt"])), response.headers["content-length"])
class HuffmanPackServingTest(PluginTestCase):
"""早期版本用 huffman 打的资源包也必须能正常服务(向后兼容)。"""
algorithm = "huffman"
def test_serves_huffman_compressed_asset(self):
response = self.get("/big.txt")
self.assertEqual(200, response.status_code)
self.assertEqual(ASSETS["big.txt"], response.content)
def test_range_works_on_huffman_pack(self):
response = self.client.get("/big.txt", headers={"range": "bytes=0-9"})
self.assertEqual(206, response.status_code)
self.assertEqual(ASSETS["big.txt"][:10], response.content)
def test_conditional_request_works_on_huffman_pack(self):
etag = self.get("/big.txt").headers["etag"]
response = self.client.get("/big.txt", headers={"if-none-match": etag})
self.assertEqual(304, response.status_code)
class MountPrefixTest(PluginTestCase):
"""挂在子路径下(app.mount("/static", ...))也要正常工作。"""
def setUp(self):
super().setUp()
app = FastAPI()
app.mount("/static", self.statics, name="static")
self.client = TestClient(app, follow_redirects=False)
def test_serves_under_prefix(self):
response = self.get("/static/app.js")
self.assertEqual(200, response.status_code)
self.assertEqual(ASSETS["app.js"], response.content)
def test_outside_prefix_is_404(self):
self.assertEqual(404, self.get("/app.js").status_code)
class ConstructionTest(NeedsFastAPI):
def test_missing_pack_raises_runtime_error_with_hint(self):
missing = self.path("nope.svfs")
with self.assertRaises(RuntimeError) as ctx:
SVFSStaticFiles(missing)
self.assertIn("vfspack", str(ctx.exception))
def test_old_format_pack_is_rejected_clearly(self):
from sqlite_vfs.core import VFSFormatError
db = helpers.make_old_schema_db(self.path("legacy.svfs"))
with self.assertRaises(VFSFormatError):
SVFSStaticFiles(db)
def test_non_database_file_is_rejected(self):
junk = helpers.write_file(self.path("junk.svfs"), b"not a database")
with self.assertRaises(RuntimeError):
SVFSStaticFiles(junk)
def test_repr_and_close(self):
src = helpers.make_tree(self.path("d"), {"a.txt": b"a"})
db = helpers.pack(src, self.path("s.svfs"))
statics = SVFSStaticFiles(db)
self.assertIn("SVFSStaticFiles", repr(statics))
statics.close()
# 关闭后再访问会报错,而不是静默返回空内容
with self.assertRaises(Exception):
statics.lookup_path("/a.txt")
class InterruptedReadTest(NeedsFastAPI):
"""返回 404/304 时不能读取 BLOB(这也是条件请求省下来的开销)。"""
def test_304_does_not_read_content(self):
src = helpers.make_tree(self.path("d"), {"a.txt": b"aaa"})
db = helpers.pack(src, self.path("s.svfs"))
statics = SVFSStaticFiles(db)
self.addCleanup(statics.close)
calls = []
original = statics._read
def counting_read(*args, **kwargs):
calls.append(args)
return original(*args, **kwargs)
statics._read = counting_read
app = FastAPI()
app.mount("/", statics, name="static")
client = TestClient(app)
first = client.get("/a.txt")
self.assertEqual(200, first.status_code)
self.assertEqual(1, len(calls))
etag = first.headers["etag"]
not_modified = client.get("/a.txt", headers={"if-none-match": etag})
self.assertEqual(304, not_modified.status_code)
self.assertEqual(1, len(calls), "304 响应不应该再去读 BLOB")
if __name__ == "__main__":
unittest.main()