mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
* fix: detect stale static bundles in build pipeline and at runtime (#1064) Add a packaging-time sanity check and a backend startup self-check that verifies index.html only references /assets/*.js and /assets/*.css files that actually exist on disk. This is the most common root cause of the 'Preparing backend...' / blank-page bug seen on the Windows immutable Release packages: vite emits a fresh index.html with new content hashes, but the desktop packaging step picks up a stale static/ directory, so the browser receives a 404 (returned as application/json) for the main bundle and refuses to execute it. - scripts/check_static_assets.py: cross-platform sanity script. - scripts/build-backend{.ps1,-macos.sh}: run the script after npm build on the source static/, and again after PyInstaller on the packaged static directory; fail the build on mismatch. - api/app.py: log an actionable ERROR when an inconsistency is detected at startup so the root cause appears in logs/desktop.log instead of surfacing as a silent blank page. - api/app.py: replace the StaticFiles mount with an explicit /assets/* route so misses return a plain-text 404 with a JS/CSS Content-Type, not the default JSON error response that masked diagnosis in #1064. - tests/test_static_assets_consistency.py: 7 deterministic tests for both code paths. Refs #1064 #1065 #1050
This commit is contained in:
123
api/app.py
123
api/app.py
@@ -15,17 +15,98 @@ FastAPI 应用工厂模块
|
||||
app = create_app()
|
||||
"""
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import unquote
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Match src="/assets/foo.js" / href="/assets/foo.css" produced by the
|
||||
# vite build. Used by the startup self-check to surface packaging
|
||||
# mismatches early (see GitHub #1064 / #1065 / #1050).
|
||||
_INDEX_ASSET_REF_PATTERN = re.compile(
|
||||
r"""(?:src|href)\s*=\s*["'](/assets/[^"']+)["']""",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_SAFE_MISSING_ASSET_MEDIA_TYPES = frozenset({"text/css", "text/javascript"})
|
||||
|
||||
|
||||
def _check_frontend_assets_consistency(static_dir: Path) -> List[str]:
|
||||
"""
|
||||
Verify that ``index.html`` only references assets that actually exist
|
||||
under ``static_dir``. Returns the list of missing references; an empty
|
||||
list means the bundle is consistent.
|
||||
|
||||
Logs an actionable error when a mismatch is detected so the root cause
|
||||
is visible in ``logs/desktop.log`` instead of surfacing as a silent
|
||||
blank page.
|
||||
"""
|
||||
index_html = static_dir / "index.html"
|
||||
if not index_html.is_file():
|
||||
return []
|
||||
try:
|
||||
html = index_html.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to read %s for asset check: %s", index_html, exc)
|
||||
return []
|
||||
|
||||
missing: List[str] = []
|
||||
for match in _INDEX_ASSET_REF_PATTERN.finditer(html):
|
||||
ref = match.group(1)
|
||||
candidate = static_dir / ref.lstrip("/")
|
||||
if not candidate.is_file() and ref not in missing:
|
||||
missing.append(ref)
|
||||
|
||||
if missing:
|
||||
logger.error(
|
||||
"Frontend bundle is inconsistent: index.html references %d asset(s) "
|
||||
"that are not present on disk under %s. This will surface as a "
|
||||
"blank page in the desktop app (see GitHub #1064 / #1065). "
|
||||
"Missing: %s. Re-run the frontend build and make sure the packaging "
|
||||
"step copies the freshly generated static/ directory.",
|
||||
len(missing),
|
||||
static_dir,
|
||||
", ".join(missing),
|
||||
)
|
||||
return missing
|
||||
|
||||
|
||||
def _resolve_asset_path(assets_dir: Path, asset_path: str) -> Optional[Path]:
|
||||
"""Resolve a requested asset path while keeping it confined to assets_dir."""
|
||||
decoded_path = unquote(asset_path)
|
||||
if not decoded_path or decoded_path.startswith(("/", "\\")):
|
||||
return None
|
||||
if "\x00" in decoded_path:
|
||||
return None
|
||||
if "\\" in decoded_path:
|
||||
return None
|
||||
if ":" in decoded_path.split("/", 1)[0]:
|
||||
return None
|
||||
|
||||
assets_root = assets_dir.resolve()
|
||||
candidate = (assets_root / decoded_path).resolve()
|
||||
if not candidate.is_relative_to(assets_root):
|
||||
return None
|
||||
return candidate
|
||||
|
||||
|
||||
def _missing_asset_media_type(asset_path: str) -> str:
|
||||
"""Return a safe media type for a missing asset response."""
|
||||
content_type, _ = mimetypes.guess_type(asset_path)
|
||||
if content_type in _SAFE_MISSING_ASSET_MEDIA_TYPES:
|
||||
return content_type
|
||||
return "text/plain"
|
||||
|
||||
from api.v1 import api_v1_router
|
||||
from api.middlewares.auth import add_auth_middleware
|
||||
@@ -121,6 +202,11 @@ def create_app(static_dir: Optional[Path] = None) -> FastAPI:
|
||||
has_frontend = static_dir.exists() and (static_dir / "index.html").exists()
|
||||
|
||||
if has_frontend:
|
||||
# Surface bundle inconsistencies as soon as the app starts so that
|
||||
# blank-page reports (#1064 / #1065 / #1050) can be diagnosed from
|
||||
# logs/desktop.log instead of via browser devtools.
|
||||
_check_frontend_assets_consistency(static_dir)
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def root():
|
||||
"""根路由 - 返回前端页面"""
|
||||
@@ -177,10 +263,37 @@ def create_app(static_dir: Optional[Path] = None) -> FastAPI:
|
||||
# ============================================================
|
||||
|
||||
if has_frontend:
|
||||
# 挂载静态资源目录
|
||||
# Serve `/assets/*` explicitly so that misses return a plain-text
|
||||
# 404 with the correct Content-Type instead of the default JSON
|
||||
# error response. JSON for a JS/CSS request is what masked the
|
||||
# blank-page root cause in #1064; here we make it obvious that the
|
||||
# static file simply does not exist on disk.
|
||||
assets_dir = static_dir / "assets"
|
||||
if assets_dir.exists():
|
||||
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
|
||||
|
||||
assets_static_files = StaticFiles(directory=str(assets_dir), check_dir=False)
|
||||
assets_root = assets_dir.resolve()
|
||||
|
||||
@app.api_route(
|
||||
"/assets/{asset_path:path}",
|
||||
methods=["GET", "HEAD"],
|
||||
include_in_schema=False,
|
||||
)
|
||||
async def serve_asset(request: Request, asset_path: str):
|
||||
file_path = _resolve_asset_path(assets_dir, asset_path)
|
||||
if file_path is None:
|
||||
return Response(
|
||||
content="not found",
|
||||
status_code=404,
|
||||
media_type="text/plain",
|
||||
)
|
||||
if file_path.is_file():
|
||||
relative_path = file_path.relative_to(assets_root).as_posix()
|
||||
return await assets_static_files.get_response(relative_path, request.scope)
|
||||
return Response(
|
||||
content="asset not found",
|
||||
status_code=404,
|
||||
media_type=_missing_asset_media_type(asset_path),
|
||||
)
|
||||
|
||||
# SPA 路由回退
|
||||
@app.get("/{full_path:path}", include_in_schema=False)
|
||||
|
||||
@@ -27,6 +27,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
- [文档] 优化根 README 结构,保留功能特性、技术栈、快速开始、推送效果、Web、Agent、赞助商和新闻源链接入口,将细配置、交易纪律和基本面语义收口到完整指南,并将 Docker 徽章指向官方镜像页
|
||||
- [文档] 同步英文与繁中 README 的精简入口结构,并补齐完整指南中的 LLM 用量 API 与持仓管理说明
|
||||
- [文档] 调整 AI 协作与 PR 模板中的 README 维护规则,明确 README 非必要不更新,细节优先进入专题文档
|
||||
- [修复] 桌面端打包链路新增 `scripts/check_static_assets.py` 静态资源一致性检查,并在 `build-backend(.ps1|-macos.sh)` 的源 `static/` 与 PyInstaller 产物里各跑一次;同时在后端启动时校验 `index.html` 引用的 `/assets/*.js`/`*.css` 是否真实存在,发现错配时直接在 `logs/desktop.log` 打印明确错误,避免重现 Release 包打开后白屏(Refs #1064 / #1065 / #1050)
|
||||
- [改进] 后端 `/assets/*` 由显式路由托管,资源缺失时返回与请求扩展名匹配的 `text/javascript` / `text/css` 404,而不是被默认 JSON 错误响应误导排查(Refs #1064)
|
||||
|
||||
## [3.13.0] - 2026-04-21
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ fi
|
||||
npm run build
|
||||
popd >/dev/null
|
||||
|
||||
log "Verifying static asset references (source)..."
|
||||
"${PYTHON_BIN}" "${SCRIPT_DIR}/check_static_assets.py" "${ROOT_DIR}/static"
|
||||
|
||||
log "Building backend executable..."
|
||||
if ! "${PYTHON_BIN}" -m PyInstaller --version >/dev/null 2>&1; then
|
||||
"${PYTHON_BIN}" -m pip install pyinstaller
|
||||
@@ -109,4 +112,15 @@ popd >/dev/null
|
||||
|
||||
cp -R "${ROOT_DIR}/dist/stock_analysis" "${ROOT_DIR}/dist/backend/stock_analysis"
|
||||
|
||||
log "Verifying static asset references (packaged)..."
|
||||
packaged_static="${ROOT_DIR}/dist/backend/stock_analysis/_internal/static"
|
||||
if [[ ! -d "${packaged_static}" ]]; then
|
||||
packaged_static="${ROOT_DIR}/dist/backend/stock_analysis/static"
|
||||
fi
|
||||
if [[ -d "${packaged_static}" ]]; then
|
||||
"${PYTHON_BIN}" "${SCRIPT_DIR}/check_static_assets.py" "${packaged_static}"
|
||||
else
|
||||
log "WARNING: could not locate packaged static directory under dist/backend/stock_analysis; skipping post-package check."
|
||||
fi
|
||||
|
||||
log "Backend build completed."
|
||||
|
||||
@@ -15,6 +15,12 @@ if ([string]::IsNullOrWhiteSpace($pythonBin)) {
|
||||
|
||||
Write-Host "Using Python: $pythonBin"
|
||||
|
||||
Write-Host 'Verifying static asset references (source)...'
|
||||
& $pythonBin "${PSScriptRoot}\check_static_assets.py" 'static'
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Static asset sanity check failed for source static/. See GitHub #1064."
|
||||
}
|
||||
|
||||
function Test-PythonCode {
|
||||
param(
|
||||
[string]$Python,
|
||||
@@ -124,4 +130,18 @@ if (!(Test-Path 'dist\stock_analysis')) {
|
||||
|
||||
Copy-Item -Path 'dist\stock_analysis' -Destination 'dist\backend\stock_analysis' -Recurse -Force
|
||||
|
||||
Write-Host 'Verifying static asset references (packaged)...'
|
||||
$packagedStatic = Join-Path 'dist\backend\stock_analysis' '_internal\static'
|
||||
if (-not (Test-Path $packagedStatic)) {
|
||||
$packagedStatic = Join-Path 'dist\backend\stock_analysis' 'static'
|
||||
}
|
||||
if (Test-Path $packagedStatic) {
|
||||
& $pythonBin "${PSScriptRoot}\check_static_assets.py" $packagedStatic
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Static asset sanity check failed for packaged $packagedStatic. See GitHub #1064."
|
||||
}
|
||||
} else {
|
||||
Write-Warning "Could not locate packaged static directory under dist\backend\stock_analysis; skipping post-package check."
|
||||
}
|
||||
|
||||
Write-Host 'Backend build completed.'
|
||||
|
||||
122
scripts/check_static_assets.py
Normal file
122
scripts/check_static_assets.py
Normal file
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Static frontend sanity check for the desktop / server packaging pipeline.
|
||||
|
||||
Validates that ``index.html`` only references ``/assets/*.js`` and
|
||||
``/assets/*.css`` files that actually exist on disk. A mismatch here is the
|
||||
most common cause of the "Preparing backend..." / blank-page bug reported in
|
||||
GitHub issues #1064, #1065, #1050: vite re-builds with a new content hash,
|
||||
but the packaging step picks up a stale ``static/`` directory or copies the
|
||||
files out of sync, so the browser receives a 404 (often as JSON) for the
|
||||
main bundle and refuses to execute it.
|
||||
|
||||
Usage:
|
||||
python scripts/check_static_assets.py [<static_dir>]
|
||||
|
||||
Exits 0 when consistent, non-zero with a human-readable message otherwise.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
|
||||
# Match src="/assets/foo.js" or href="/assets/foo.css", with single or double
|
||||
# quotes. Vite emits absolute paths by default (``base: '/'``).
|
||||
_ASSET_PATTERN = re.compile(
|
||||
r"""(?:src|href)\s*=\s*["'](/assets/[^"']+)["']""",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _parse_referenced_assets(index_html: str) -> List[str]:
|
||||
"""Return the unique list of ``/assets/...`` paths referenced by ``index.html``."""
|
||||
seen: List[str] = []
|
||||
for match in _ASSET_PATTERN.finditer(index_html):
|
||||
ref = match.group(1)
|
||||
if ref not in seen:
|
||||
seen.append(ref)
|
||||
return seen
|
||||
|
||||
|
||||
def check_static_dir(static_dir: Path) -> Tuple[List[str], List[str]]:
|
||||
"""
|
||||
Inspect ``static_dir`` and return ``(referenced, missing)``.
|
||||
|
||||
``referenced`` is the list of ``/assets/...`` paths declared in
|
||||
``index.html``. ``missing`` is the subset that does not exist on disk.
|
||||
Raises ``FileNotFoundError`` if ``index.html`` itself is missing.
|
||||
"""
|
||||
index_html_path = static_dir / "index.html"
|
||||
if not index_html_path.is_file():
|
||||
raise FileNotFoundError(f"index.html not found under {static_dir}")
|
||||
|
||||
html = index_html_path.read_text(encoding="utf-8", errors="replace")
|
||||
referenced = _parse_referenced_assets(html)
|
||||
|
||||
missing: List[str] = []
|
||||
for ref in referenced:
|
||||
# ref looks like "/assets/index-xxx.js"; strip the leading slash so
|
||||
# it resolves relative to ``static_dir``.
|
||||
candidate = static_dir / ref.lstrip("/")
|
||||
if not candidate.is_file():
|
||||
missing.append(ref)
|
||||
return referenced, missing
|
||||
|
||||
|
||||
def main(argv: List[str]) -> int:
|
||||
if len(argv) > 1:
|
||||
static_dir = Path(argv[1]).resolve()
|
||||
else:
|
||||
static_dir = (Path(__file__).resolve().parent.parent / "static").resolve()
|
||||
|
||||
print(f"[check_static_assets] inspecting {static_dir}")
|
||||
|
||||
try:
|
||||
referenced, missing = check_static_dir(static_dir)
|
||||
except FileNotFoundError as exc:
|
||||
print(f"[check_static_assets] ERROR: {exc}", file=sys.stderr)
|
||||
print(
|
||||
"[check_static_assets] Hint: build the frontend first via "
|
||||
"`cd apps/dsa-web && npm install && npm run build`.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
if not referenced:
|
||||
print(
|
||||
"[check_static_assets] WARNING: index.html does not reference any "
|
||||
"/assets/* files; this is unusual for a vite build.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
|
||||
if missing:
|
||||
print(
|
||||
"[check_static_assets] ERROR: index.html references assets that "
|
||||
"are not present on disk:",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for ref in missing:
|
||||
print(f" - {ref}", file=sys.stderr)
|
||||
print(
|
||||
"[check_static_assets] This produces a blank page on first load "
|
||||
"(see GitHub #1064 / #1065). Re-run the frontend build and make "
|
||||
"sure the packaging step copies the freshly generated static/ "
|
||||
"directory.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
print(
|
||||
f"[check_static_assets] OK: {len(referenced)} asset reference(s) "
|
||||
f"resolved successfully."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
268
tests/test_static_assets_consistency.py
Normal file
268
tests/test_static_assets_consistency.py
Normal file
@@ -0,0 +1,268 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for ``scripts/check_static_assets.py`` and the equivalent
|
||||
backend startup self-check in ``api.app``.
|
||||
|
||||
Both code paths target the blank-page / "Preparing backend..." regression
|
||||
captured in GitHub issues #1064, #1065 and #1050: vite produces a fresh
|
||||
``index.html`` that references ``/assets/index-<hash>.js``, but the
|
||||
packaging step copies a stale ``static/assets`` directory, so the bundle
|
||||
referenced by ``index.html`` does not exist on disk.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent.parent / "scripts"
|
||||
if str(SCRIPT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
check_static_assets = importlib.import_module("check_static_assets")
|
||||
|
||||
|
||||
def _write_index(static_dir: Path, body: str) -> None:
|
||||
static_dir.mkdir(parents=True, exist_ok=True)
|
||||
(static_dir / "index.html").write_text(body, encoding="utf-8")
|
||||
|
||||
|
||||
def _vite_index(js_name: str, css_name: str) -> str:
|
||||
return (
|
||||
"<!doctype html><html><head>"
|
||||
f'<script type="module" crossorigin src="/assets/{js_name}"></script>'
|
||||
f'<link rel="stylesheet" crossorigin href="/assets/{css_name}">'
|
||||
"</head><body><div id=\"root\"></div></body></html>"
|
||||
)
|
||||
|
||||
|
||||
def test_check_static_dir_passes_when_assets_match(tmp_path: Path) -> None:
|
||||
static_dir = tmp_path / "static"
|
||||
assets_dir = static_dir / "assets"
|
||||
assets_dir.mkdir(parents=True)
|
||||
(assets_dir / "index-abc.js").write_text("// js", encoding="utf-8")
|
||||
(assets_dir / "index-abc.css").write_text("/* css */", encoding="utf-8")
|
||||
_write_index(static_dir, _vite_index("index-abc.js", "index-abc.css"))
|
||||
|
||||
referenced, missing = check_static_assets.check_static_dir(static_dir)
|
||||
|
||||
assert sorted(referenced) == ["/assets/index-abc.css", "/assets/index-abc.js"]
|
||||
assert missing == []
|
||||
|
||||
|
||||
def test_check_static_dir_detects_stale_bundle(tmp_path: Path) -> None:
|
||||
"""index.html references new hash but assets/ holds an old hash."""
|
||||
static_dir = tmp_path / "static"
|
||||
assets_dir = static_dir / "assets"
|
||||
assets_dir.mkdir(parents=True)
|
||||
# Stale on-disk bundle.
|
||||
(assets_dir / "index-OLD.js").write_text("// old", encoding="utf-8")
|
||||
(assets_dir / "index-OLD.css").write_text("/* old */", encoding="utf-8")
|
||||
# Fresh index.html points to a different hash.
|
||||
_write_index(static_dir, _vite_index("index-NEW.js", "index-NEW.css"))
|
||||
|
||||
referenced, missing = check_static_assets.check_static_dir(static_dir)
|
||||
|
||||
assert "/assets/index-NEW.js" in referenced
|
||||
assert sorted(missing) == ["/assets/index-NEW.css", "/assets/index-NEW.js"]
|
||||
|
||||
|
||||
def test_check_static_dir_raises_when_index_missing(tmp_path: Path) -> None:
|
||||
static_dir = tmp_path / "static"
|
||||
static_dir.mkdir()
|
||||
with pytest.raises(FileNotFoundError):
|
||||
check_static_assets.check_static_dir(static_dir)
|
||||
|
||||
|
||||
def test_main_returns_nonzero_when_assets_missing(tmp_path: Path, capsys) -> None:
|
||||
static_dir = tmp_path / "static"
|
||||
(static_dir / "assets").mkdir(parents=True)
|
||||
_write_index(static_dir, _vite_index("index-MISSING.js", "index-MISSING.css"))
|
||||
|
||||
rc = check_static_assets.main(["check_static_assets.py", str(static_dir)])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert rc == 1
|
||||
assert "ERROR" in captured.err
|
||||
assert "/assets/index-MISSING.js" in captured.err
|
||||
|
||||
|
||||
def test_main_returns_zero_when_consistent(tmp_path: Path) -> None:
|
||||
static_dir = tmp_path / "static"
|
||||
assets = static_dir / "assets"
|
||||
assets.mkdir(parents=True)
|
||||
(assets / "main.js").write_text("// ok", encoding="utf-8")
|
||||
(assets / "main.css").write_text("/* ok */", encoding="utf-8")
|
||||
_write_index(static_dir, _vite_index("main.js", "main.css"))
|
||||
|
||||
rc = check_static_assets.main(["check_static_assets.py", str(static_dir)])
|
||||
assert rc == 0
|
||||
|
||||
|
||||
def test_backend_startup_check_logs_when_bundle_inconsistent(
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
from api import app as app_module
|
||||
|
||||
static_dir = tmp_path / "static"
|
||||
(static_dir / "assets").mkdir(parents=True)
|
||||
_write_index(static_dir, _vite_index("index-NEW.js", "index-NEW.css"))
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="api.app"):
|
||||
missing = app_module._check_frontend_assets_consistency(static_dir)
|
||||
|
||||
assert sorted(missing) == ["/assets/index-NEW.css", "/assets/index-NEW.js"]
|
||||
assert any(
|
||||
"Frontend bundle is inconsistent" in record.getMessage()
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_backend_startup_check_silent_when_bundle_consistent(
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
from api import app as app_module
|
||||
|
||||
static_dir = tmp_path / "static"
|
||||
assets = static_dir / "assets"
|
||||
assets.mkdir(parents=True)
|
||||
(assets / "index-abc.js").write_text("// js", encoding="utf-8")
|
||||
(assets / "index-abc.css").write_text("/* css */", encoding="utf-8")
|
||||
_write_index(static_dir, _vite_index("index-abc.js", "index-abc.css"))
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="api.app"):
|
||||
missing = app_module._check_frontend_assets_consistency(static_dir)
|
||||
|
||||
assert missing == []
|
||||
assert not any(
|
||||
"Frontend bundle is inconsistent" in record.getMessage()
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_missing_asset_returns_safe_404_content_types(tmp_path: Path) -> None:
|
||||
from api.app import create_app
|
||||
|
||||
static_dir = tmp_path / "static"
|
||||
assets_dir = static_dir / "assets"
|
||||
assets_dir.mkdir(parents=True)
|
||||
(assets_dir / "index-abc.js").write_text("// ok", encoding="utf-8")
|
||||
(assets_dir / "index-abc.css").write_text("/* ok */", encoding="utf-8")
|
||||
_write_index(static_dir, _vite_index("index-abc.js", "index-abc.css"))
|
||||
|
||||
client = TestClient(create_app(static_dir=static_dir))
|
||||
|
||||
js_response = client.get("/assets/index-missing.js")
|
||||
css_response = client.get("/assets/index-missing.css")
|
||||
html_response = client.get("/assets/%3Cscript%3Ealert(1)%3C/script%3E.html")
|
||||
|
||||
assert js_response.status_code == 404
|
||||
assert js_response.text == "asset not found"
|
||||
assert js_response.headers["content-type"].startswith("text/javascript")
|
||||
|
||||
assert css_response.status_code == 404
|
||||
assert css_response.text == "asset not found"
|
||||
assert css_response.headers["content-type"].startswith("text/css")
|
||||
|
||||
assert html_response.status_code == 404
|
||||
assert html_response.text == "asset not found"
|
||||
assert html_response.headers["content-type"].startswith("text/plain")
|
||||
|
||||
|
||||
def test_existing_asset_is_served_from_explicit_assets_route(tmp_path: Path) -> None:
|
||||
from api.app import create_app
|
||||
|
||||
static_dir = tmp_path / "static"
|
||||
assets_dir = static_dir / "assets"
|
||||
assets_dir.mkdir(parents=True)
|
||||
js_file = assets_dir / "index-abc.js"
|
||||
css_file = assets_dir / "index-abc.css"
|
||||
js_file.write_text("console.log('ok')", encoding="utf-8")
|
||||
css_file.write_text("body{color:#fff}", encoding="utf-8")
|
||||
_write_index(static_dir, _vite_index("index-abc.js", "index-abc.css"))
|
||||
|
||||
client = TestClient(create_app(static_dir=static_dir))
|
||||
|
||||
js_response = client.get("/assets/index-abc.js")
|
||||
css_response = client.get("/assets/index-abc.css")
|
||||
|
||||
assert js_response.status_code == 200
|
||||
assert js_response.text == "console.log('ok')"
|
||||
assert js_response.headers["content-type"].startswith("text/javascript")
|
||||
|
||||
assert css_response.status_code == 200
|
||||
assert css_response.text == "body{color:#fff}"
|
||||
assert css_response.headers["content-type"].startswith("text/css")
|
||||
|
||||
|
||||
def test_existing_asset_supports_head_and_conditional_requests(tmp_path: Path) -> None:
|
||||
from api.app import create_app
|
||||
|
||||
static_dir = tmp_path / "static"
|
||||
assets_dir = static_dir / "assets"
|
||||
assets_dir.mkdir(parents=True)
|
||||
js_file = assets_dir / "index-abc.js"
|
||||
js_file.write_text("console.log('ok')", encoding="utf-8")
|
||||
(assets_dir / "index-abc.css").write_text("body{color:#fff}", encoding="utf-8")
|
||||
_write_index(static_dir, _vite_index("index-abc.js", "index-abc.css"))
|
||||
|
||||
client = TestClient(create_app(static_dir=static_dir))
|
||||
|
||||
get_response = client.get("/assets/index-abc.js")
|
||||
etag = get_response.headers["etag"]
|
||||
|
||||
head_response = client.head("/assets/index-abc.js")
|
||||
cached_response = client.get(
|
||||
"/assets/index-abc.js",
|
||||
headers={"if-none-match": etag},
|
||||
)
|
||||
|
||||
assert get_response.status_code == 200
|
||||
assert head_response.status_code == 200
|
||||
assert head_response.content == b""
|
||||
assert head_response.headers["etag"] == etag
|
||||
assert head_response.headers["content-type"].startswith("text/javascript")
|
||||
|
||||
assert cached_response.status_code == 304
|
||||
assert cached_response.content == b""
|
||||
assert cached_response.headers["etag"] == etag
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"request_path",
|
||||
[
|
||||
"/assets/..%5C..%5Csecret.txt",
|
||||
"/assets/C:%5Csecret.txt",
|
||||
"/assets/%5Cwindows%5Csystem32%5Cconfig",
|
||||
"/assets/%2e%2e/%2e%2e/secret.txt",
|
||||
"/assets/%2500.js",
|
||||
],
|
||||
)
|
||||
def test_asset_traversal_attempts_are_rejected(
|
||||
tmp_path: Path,
|
||||
request_path: str,
|
||||
) -> None:
|
||||
from api.app import create_app
|
||||
|
||||
static_dir = tmp_path / "static"
|
||||
assets_dir = static_dir / "assets"
|
||||
assets_dir.mkdir(parents=True)
|
||||
(assets_dir / "index-abc.js").write_text("// ok", encoding="utf-8")
|
||||
(assets_dir / "index-abc.css").write_text("/* ok */", encoding="utf-8")
|
||||
_write_index(static_dir, _vite_index("index-abc.js", "index-abc.css"))
|
||||
outside_secret = tmp_path / "secret.txt"
|
||||
outside_secret.write_text("top secret", encoding="utf-8")
|
||||
|
||||
client = TestClient(create_app(static_dir=static_dir))
|
||||
response = client.get(request_path)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.text == "not found"
|
||||
assert "top secret" not in response.text
|
||||
Reference in New Issue
Block a user