起始状态:主链路 100% 不可用,而且失败被静默吞掉——打包后库里 0 个文件、 界面仍打印"文件数量: N";解包导出 0 个文件并在磁盘上生成几百层 sub/sub/sub 垃圾目录。 缺陷修复(4 个 P0 + 12 个 P1,逐条见 BUG_REPORT.md): - 建表列名 compresse_algo 与 INSERT/read_file 用的 compress_algo 不一致 - list_directory 按 path 而非 parent_path 过滤,导致导出无限自递归 - CLI --ignore-timestamps 把 export_filesystem 赋成引用自身的 lambda - add_file 缺少 commit(直接调用 API 时静默丢数据) - LIKE 前缀误伤兄弟目录(删 doc 连带删 docs);mv_dir 二次改名 - 失败路径一律返回 exit code 0;--exclude 是子串匹配而非 glob - FolderPacker.add_file 反向调用 export_file_safe,会用库内内容覆盖源文件 存储层(schema v3,格式变更,提供 vfsmigrate 迁移): - 内容移入 blobs(hash PRIMARY KEY):相同内容只存一份,跨路径/跨快照共享 - files 的唯一约束改为 UNIQUE(snapshot_id, path):一个库存多个版本 - 完整性:每文件 sha256 + 快照级摘要 + HMAC-SHA256 签名,只读连接即可验证 - 增量打包:按 mtime+size 或 sha256 只写变化项,未变文件不重写不重压 - get_file_info 不再返回 content(元数据查询不该搬 BLOB) 新增能力: - 可选 FastAPI 静态文件插件(extras,核心仍零第三方依赖) - 内容寻址发布到对象存储 + 逐对象校验的取回客户端 - 压缩默认改回 zlib:实测比自研哈夫曼体积小 1/3~1/170、快数百倍 其它: - P2-4:时间戳显式转字符串,清掉 1240 条 DeprecationWarning(盘上格式不变) - read_file 负数 offset/size 改为报错(原先会走切片语义从末尾取) - requires-python 从 >=3.7 改为按实际 API 推导的 >=3.9 测试:0 → 343 个用例,纯标准库 unittest,有/无 FastAPI 两种环境都跑通。 证据见 docs/evidence-*.txt。
484 lines
19 KiB
Python
484 lines
19 KiB
Python
"""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()
|