"""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"
sub page
", "sub/index.html": b"sub index
", "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()